7 Commits

12 changed files with 266 additions and 77 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_Config.get("ProtocolVersion"),
bootedAt = bootedAt,
uptimeSeconds = (now - bootedAt) / 1000,
paused = isPaused(),
rateLimit = RDB_Security.getRateLimitStatus(),
config = RDB_Config.getAll(),
}
end,
})
end

View File

@@ -12,9 +12,19 @@ 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_Config.get("DebugSelfTest") then
return return
end end
@@ -35,11 +45,12 @@ end
local function registerBaseOptions() local function registerBaseOptions()
local OPT = RDB_Constants.OPT local OPT = RDB_Constants.OPT
RDB_OptionsRegistry.register(OPT.PROTOCOL_VERSION, "{}", RDB_Constants.OPTION_MAX_LENGTH) local optionMaxLength = RDB_Config.get("OptionMaxLength")
RDB_OptionsRegistry.register(OPT.WORLD_STATS, "{}", RDB_Constants.OPTION_MAX_LENGTH) RDB_OptionsRegistry.register(OPT.PROTOCOL_VERSION, "{}", optionMaxLength)
RDB_OptionsRegistry.register(OPT.REQUEST, "{}", RDB_Constants.OPTION_MAX_LENGTH) RDB_OptionsRegistry.register(OPT.WORLD_STATS, "{}", optionMaxLength)
RDB_OptionsRegistry.register(OPT.RESPONSE, "{}", RDB_Constants.OPTION_MAX_LENGTH) RDB_OptionsRegistry.register(OPT.REQUEST, "{}", optionMaxLength)
RDB_OptionsRegistry.register(OPT.LAST_RESPONSE, "0", RDB_Constants.OPTION_MAX_LENGTH) RDB_OptionsRegistry.register(OPT.RESPONSE, "{}", optionMaxLength)
RDB_OptionsRegistry.register(OPT.LAST_RESPONSE, "0", optionMaxLength)
end end
local function registerOps() local function registerOps()
@@ -47,11 +58,13 @@ 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()
local json = RDB_Json.encode({ local json = RDB_Json.encode({
version = RDB_Constants.PROTOCOL_VERSION, version = RDB_Config.get("ProtocolVersion"),
ops = RDB_OpRegistry.listNames(), ops = RDB_OpRegistry.listNames(),
}) })
@@ -59,8 +72,10 @@ local function publishProtocolVersion()
end end
local function onServerStarted() local function onServerStarted()
if not bootTimestamp then
bootTimestamp = getTimestampMs()
end
runSelfTests() runSelfTests()
RDB_Config.init()
registerBaseOptions() registerBaseOptions()
registerOps() registerOps()
publishProtocolVersion() publishProtocolVersion()

View File

@@ -1,45 +1,45 @@
---@diagnostic disable: need-check-nil ---@diagnostic disable: need-check-nil
-- ModData-backed bridge configuration: rate limits, payload cap, sensitive -- Facade over ALL scalar constants/settings -- the single point of access,
-- field policy. Defaults are baked in and only written to the ModData table -- so no other file should index RDB_Constants directly for a scalar value.
-- the first time it's created. -- Sandbox-tunable values (rate limits, poll cadence, idempotency TTL, debug
-- self-tests) live in
-- Contents/mods/RconDataBridge/common/media/sandbox-options.txt and are read
-- live from SandboxVars.RconDataBridge.*. Anything NOT sandbox-tunable (e.g.
-- MaxPayloadBytes/OptionMaxLength, sized against the fixed TextServerOption
-- capacity -- base64 expansion of the payload must still fit, so an
-- admin-set value with no matching engine-side capacity change could
-- silently truncate a request ServerOptions can't hold; or ProtocolVersion/
-- PlayerStatsPrefix, protocol identifiers, not settings) instead falls back
-- to a same-named RDB_Constants field. Adding either kind of value never
-- needs a new branch here -- just a sandbox-options.txt entry or a
-- RDB_Constants field. (Unit conversions, like IdempotencyTtlSeconds ->
-- milliseconds, belong in the one caller that needs the converted unit, not
-- here -- see RDB_Idempotency.)
require("RconDataBridge.RDB_Constants") require("RconDataBridge.RDB_Constants")
RDB_Config = {} RDB_Config = {}
local TAG = "RconDataBridge_Config" -- PascalCase (sandbox-var convention, e.g. "MaxPayloadBytes") ->
-- SCREAMING_SNAKE_CASE (RDB_Constants convention, "MAX_PAYLOAD_BYTES").
local DEFAULTS = { local function toConstantKey(key)
maxPayloadBytes = RDB_Constants.MAX_PAYLOAD_BYTES, return (key:gsub("(%l)(%u)", "%1_%2")):upper()
rateLimitMaxRequests = 10,
rateLimitWindowSeconds = 10,
includeSensitiveFields = false,
}
local store
function RDB_Config.init()
store = ModData.getOrCreate(TAG)
for key, value in pairs(DEFAULTS) do
if store[key] == nil then
store[key] = value
end
end
end end
function RDB_Config.get(key) function RDB_Config.get(key)
if not store then local v = SandboxVars.RconDataBridge[key]
RDB_Config.init() if v ~= nil then
return v
end end
return store[key] return RDB_Constants[toConstantKey(key)]
end end
function RDB_Config.set(key, value) function RDB_Config.getAll()
if not store then return {
RDB_Config.init() maxPayloadBytes = RDB_Config.get("MaxPayloadBytes"),
end rateLimitMaxRequests = RDB_Config.get("RateLimitMaxRequests"),
rateLimitWindowSeconds = RDB_Config.get("RateLimitWindowSeconds"),
store[key] = value }
end end

View File

@@ -2,14 +2,15 @@
-- the original response instead of re-executing the op. -- the original response instead of re-executing the op.
-- Persisted via ModData so it survives restarts. -- Persisted via ModData so it survives restarts.
-- --
-- Entries expire after RDB_Constants.IDEMPOTENCY_TTL_MS (60s by default): -- Entries expire after RDB_Config.get("IdempotencyTtlSeconds") (60s by
-- a request id is only idempotent for retries within that window, not -- default, admin-tunable via sandbox-options.txt): a request id is only
-- forever. Without a TTL, a client that reuses a fixed id for routine -- idempotent for retries within that window, not forever. Without a TTL, a
-- polling would get the first-ever response back on every call, since a -- client that reuses a fixed id for routine polling would get the
-- low-traffic server would rarely reach MAX_ENTRIES and trigger the -- first-ever response back on every call, since a low-traffic server would
-- count-based eviction below. -- rarely reach MAX_ENTRIES and trigger the count-based eviction below.
require("RconDataBridge.RDB_Constants") require("RconDataBridge.RDB_Constants")
require("RconDataBridge.RDB_Config")
require("RconDataBridge.RDB_Log") require("RconDataBridge.RDB_Log")
RDB_Idempotency = {} RDB_Idempotency = {}
@@ -27,7 +28,8 @@ local function ensureStore()
end end
local function isExpired(entry) local function isExpired(entry)
return getTimestampMs() - (entry.ts or 0) >= RDB_Constants.IDEMPOTENCY_TTL_MS local ttlMs = RDB_Config.get("IdempotencyTtlSeconds") * 1000
return getTimestampMs() - (entry.ts or 0) >= ttlMs
end end
function RDB_Idempotency.has(id) function RDB_Idempotency.has(id)

View File

@@ -14,6 +14,7 @@ require("RconDataBridge.RDB_Constants")
require("RconDataBridge.RDB_Log") require("RconDataBridge.RDB_Log")
require("RconDataBridge.RDB_Json") require("RconDataBridge.RDB_Json")
require("RconDataBridge.RDB_OptionsRegistry") require("RconDataBridge.RDB_OptionsRegistry")
require("RconDataBridge.RDB_Config")
RDB_PlayerSnapshot = {} RDB_PlayerSnapshot = {}
@@ -78,8 +79,8 @@ function RDB_PlayerSnapshot.publishStatsOption(id)
return return
end end
local optionName = RDB_Constants.PLAYER_STATS_PREFIX .. id:gsub("[^%w_]", "_") local optionName = RDB_Config.get("PlayerStatsPrefix") .. id:gsub("[^%w_]", "_")
RDB_OptionsRegistry.register(optionName, "{}", RDB_Constants.OPTION_MAX_LENGTH) RDB_OptionsRegistry.register(optionName, "{}", RDB_Config.get("OptionMaxLength"))
RDB_OptionsRegistry.set(optionName, RDB_Json.encode(snapshot)) RDB_OptionsRegistry.set(optionName, RDB_Json.encode(snapshot))
end end
@@ -133,7 +134,7 @@ local initialized = false
local function onTick() local function onTick()
tickCounter = tickCounter + 1 tickCounter = tickCounter + 1
if tickCounter < RDB_Constants.POLL_EVERY_TICKS then if tickCounter < RDB_Config.get("PollEveryTicks") then
return return
end end

View File

@@ -7,6 +7,7 @@ require("RconDataBridge.RDB_Json")
require("RconDataBridge.RDB_Base64") require("RconDataBridge.RDB_Base64")
require("RconDataBridge.RDB_Log") require("RconDataBridge.RDB_Log")
require("RconDataBridge.RDB_OptionsRegistry") require("RconDataBridge.RDB_OptionsRegistry")
require("RconDataBridge.RDB_Config")
require("RconDataBridge.RDB_Security") require("RconDataBridge.RDB_Security")
require("RconDataBridge.RDB_Idempotency") require("RconDataBridge.RDB_Idempotency")
require("RconDataBridge.RDB_AuditLog") require("RconDataBridge.RDB_AuditLog")
@@ -19,7 +20,7 @@ local tickCounter = 0
local function buildOkResponse(id, data) local function buildOkResponse(id, data)
return { return {
v = RDB_Constants.PROTOCOL_VERSION, v = RDB_Config.get("ProtocolVersion"),
id = id, id = id,
ok = true, ok = true,
data = data or {} data = data or {}
@@ -28,7 +29,7 @@ end
local function buildErrorResponse(id, code, message) local function buildErrorResponse(id, code, message)
return { return {
v = RDB_Constants.PROTOCOL_VERSION, v = RDB_Config.get("ProtocolVersion"),
id = id, id = id,
ok = false, ok = false,
error = { code = code, message = message }, error = { code = code, message = message },
@@ -44,6 +45,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 +106,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 +123,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,12 +133,17 @@ 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()
tickCounter = tickCounter + 1 tickCounter = tickCounter + 1
if tickCounter < RDB_Constants.POLL_EVERY_TICKS then if tickCounter < RDB_Config.get("PollEveryTicks") then
return return
end end
tickCounter = 0 tickCounter = 0
@@ -138,14 +162,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

@@ -11,7 +11,7 @@ require("RconDataBridge.RDB_Config")
RDB_Security = {} RDB_Security = {}
function RDB_Security.checkPayloadSize(raw) function RDB_Security.checkPayloadSize(raw)
if #raw > RDB_Config.get("maxPayloadBytes") then if #raw > RDB_Config.get("MaxPayloadBytes") then
return false, RDB_Constants.ERROR_CODES.PAYLOAD_TOO_LARGE, "Request payload exceeds size limit" return false, RDB_Constants.ERROR_CODES.PAYLOAD_TOO_LARGE, "Request payload exceeds size limit"
end end
@@ -24,7 +24,7 @@ function RDB_Security.validateEnvelope(obj)
if type(obj) ~= "table" then if type(obj) ~= "table" then
return false, RDB_Constants.ERROR_CODES.SCHEMA_ERROR, "Request must be a JSON object" return false, RDB_Constants.ERROR_CODES.SCHEMA_ERROR, "Request must be a JSON object"
end end
if obj.v ~= RDB_Constants.PROTOCOL_VERSION then if obj.v ~= RDB_Config.get("ProtocolVersion") then
return false, RDB_Constants.ERROR_CODES.SCHEMA_ERROR, "Unsupported or missing protocol version" return false, RDB_Constants.ERROR_CODES.SCHEMA_ERROR, "Unsupported or missing protocol version"
end end
if type(obj.id) ~= "string" or #obj.id == 0 or #obj.id > 128 then if type(obj.id) ~= "string" or #obj.id == 0 or #obj.id > 128 then
@@ -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

View File

@@ -28,7 +28,7 @@ local function refresh()
minute = gt:getMinutes(), minute = gt:getMinutes(),
}, },
isNight = gt:isNight(), isNight = gt:isNight(),
isRaining = gt:isRainingToday(), isRaining = RainManager.isRaining(), -- GameTime:isRainingToday() is a dead stub in B42.20
timeMultiplier = gt:getMultiplier(), timeMultiplier = gt:getMultiplier(),
generatedAt = getTimestampMs(), generatedAt = getTimestampMs(),
} }

View File

@@ -6,8 +6,6 @@
RDB_Constants = {} RDB_Constants = {}
RDB_Constants.DEBUG = true
RDB_Constants.PROTOCOL_VERSION = 1 RDB_Constants.PROTOCOL_VERSION = 1
RDB_Constants.OPT = { RDB_Constants.OPT = {
@@ -22,16 +20,6 @@ RDB_Constants.PLAYER_STATS_PREFIX = "RconDataBridge_PlayerStats_"
RDB_Constants.MAX_PAYLOAD_BYTES = 4096 RDB_Constants.MAX_PAYLOAD_BYTES = 4096
RDB_Constants.OPTION_MAX_LENGTH = 8192 RDB_Constants.OPTION_MAX_LENGTH = 8192
RDB_Constants.POLL_EVERY_TICKS = 30
-- How long a request id's cached response is replayed for on retry before
-- it's treated as expired and the op runs again. Deliberately short: a
-- client that reuses a fixed id for routine polling (e.g. "ws-poll" every
-- few minutes) should get a fresh answer each time, not the first-ever
-- response forever. A count-based cap alone (MAX_ENTRIES in
-- RDB_Idempotency) doesn't help here, it only evicts under high request
-- volume, never under low-and-slow polling, which is exactly this case.
RDB_Constants.IDEMPOTENCY_TTL_MS = 60 * 1000
RDB_Constants.ERROR_CODES = { RDB_Constants.ERROR_CODES = {
SCHEMA_ERROR = "SCHEMA_ERROR", SCHEMA_ERROR = "SCHEMA_ERROR",

View File

@@ -0,0 +1,13 @@
{
"Sandbox_RconDataBridge": "RCON Data Bridge",
"Sandbox_RconDataBridge_DebugSelfTest": "Debug Self-Tests",
"Sandbox_RconDataBridge_DebugSelfTest_tooltip": "Run RDB_Json/RDB_Base64 self-tests and log the result on every server boot.",
"Sandbox_RconDataBridge_PollEveryTicks": "Poll Interval (ticks)",
"Sandbox_RconDataBridge_PollEveryTicks_tooltip": "How many server ticks between checks of the request mailbox and the online-player roster. Lower is more responsive, higher is less overhead.",
"Sandbox_RconDataBridge_IdempotencyTtlSeconds": "Idempotency Window (seconds)",
"Sandbox_RconDataBridge_IdempotencyTtlSeconds_tooltip": "How long a repeated request id replays its cached response instead of running again.",
"Sandbox_RconDataBridge_RateLimitMaxRequests": "Rate Limit: Max Requests",
"Sandbox_RconDataBridge_RateLimitMaxRequests_tooltip": "Maximum RCON Data Bridge requests allowed per rate-limit window.",
"Sandbox_RconDataBridge_RateLimitWindowSeconds": "Rate Limit: Window (seconds)",
"Sandbox_RconDataBridge_RateLimitWindowSeconds_tooltip": "Length of the rolling rate-limit window, in seconds."
}

View File

@@ -0,0 +1,49 @@
VERSION = 1,
option RconDataBridge.DebugSelfTest
{
type = boolean,
default = true,
page = RconDataBridge,
translation = RconDataBridge_DebugSelfTest,
}
option RconDataBridge.PollEveryTicks
{
type = integer,
min = 1,
max = 300,
default = 30,
page = RconDataBridge,
translation = RconDataBridge_PollEveryTicks,
}
option RconDataBridge.IdempotencyTtlSeconds
{
type = integer,
min = 1,
max = 3600,
default = 60,
page = RconDataBridge,
translation = RconDataBridge_IdempotencyTtlSeconds,
}
option RconDataBridge.RateLimitMaxRequests
{
type = integer,
min = 1,
max = 1000,
default = 10,
page = RconDataBridge,
translation = RconDataBridge_RateLimitMaxRequests,
}
option RconDataBridge.RateLimitWindowSeconds
{
type = integer,
min = 1,
max = 3600,
default = 10,
page = RconDataBridge,
translation = RconDataBridge_RateLimitWindowSeconds,
}