Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 60c2c718eb | |||
| dd801de28d | |||
| 439105ec60 | |||
| 854c19dca7 |
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user