From 2aaa3278537e284f9ace48356d59a41460ad78b1 Mon Sep 17 00:00:00 2001 From: Overlord Date: Thu, 27 Aug 2026 16:11:00 +0200 Subject: [PATCH] Make RCON Data Bridge configuration sandbox-tunable and refactor constants access --- .../RconDataBridge/Ops/RDB_OpBridgeStatus.lua | 2 +- .../server/RconDataBridge/RDB_Bootstrap.lua | 16 ++--- .../lua/server/RconDataBridge/RDB_Config.lua | 71 ++++++++----------- .../server/RconDataBridge/RDB_Idempotency.lua | 16 +++-- .../RconDataBridge/RDB_PlayerSnapshot.lua | 7 +- .../RconDataBridge/RDB_RequestPipeline.lua | 7 +- .../server/RconDataBridge/RDB_Security.lua | 12 ++-- .../shared/RconDataBridge/RDB_Constants.lua | 12 ---- .../lua/shared/Translate/EN/Sandbox.json | 13 ++++ .../common/media/sandbox-options.txt | 49 +++++++++++++ 10 files changed, 123 insertions(+), 82 deletions(-) create mode 100644 Contents/mods/RconDataBridge/common/media/lua/shared/Translate/EN/Sandbox.json 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 895ac9c..35908f8 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 @@ -20,7 +20,7 @@ function RDB_OpBridgeStatus.register() local now = getTimestampMs() return true, { - version = RDB_Constants.PROTOCOL_VERSION, + version = RDB_Config.get("ProtocolVersion"), bootedAt = bootedAt, uptimeSeconds = (now - bootedAt) / 1000, paused = isPaused(), 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 576a4b9..f38908a 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 @@ -24,7 +24,7 @@ function RDB_Bootstrap.getBootTimestamp() end local function runSelfTests() - if not RDB_Constants.DEBUG then + if not RDB_Config.get("DebugSelfTest") then return end @@ -45,11 +45,12 @@ end local function registerBaseOptions() local OPT = RDB_Constants.OPT - RDB_OptionsRegistry.register(OPT.PROTOCOL_VERSION, "{}", RDB_Constants.OPTION_MAX_LENGTH) - RDB_OptionsRegistry.register(OPT.WORLD_STATS, "{}", RDB_Constants.OPTION_MAX_LENGTH) - RDB_OptionsRegistry.register(OPT.REQUEST, "{}", RDB_Constants.OPTION_MAX_LENGTH) - RDB_OptionsRegistry.register(OPT.RESPONSE, "{}", RDB_Constants.OPTION_MAX_LENGTH) - RDB_OptionsRegistry.register(OPT.LAST_RESPONSE, "0", RDB_Constants.OPTION_MAX_LENGTH) + local optionMaxLength = RDB_Config.get("OptionMaxLength") + RDB_OptionsRegistry.register(OPT.PROTOCOL_VERSION, "{}", optionMaxLength) + RDB_OptionsRegistry.register(OPT.WORLD_STATS, "{}", optionMaxLength) + RDB_OptionsRegistry.register(OPT.REQUEST, "{}", optionMaxLength) + RDB_OptionsRegistry.register(OPT.RESPONSE, "{}", optionMaxLength) + RDB_OptionsRegistry.register(OPT.LAST_RESPONSE, "0", optionMaxLength) end local function registerOps() @@ -63,7 +64,7 @@ end local function publishProtocolVersion() local json = RDB_Json.encode({ - version = RDB_Constants.PROTOCOL_VERSION, + version = RDB_Config.get("ProtocolVersion"), ops = RDB_OpRegistry.listNames(), }) @@ -75,7 +76,6 @@ local function onServerStarted() bootTimestamp = getTimestampMs() end runSelfTests() - RDB_Config.init() registerBaseOptions() registerOps() publishProtocolVersion() 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 29c542f..8760c2d 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 @@ -1,58 +1,45 @@ ---@diagnostic disable: need-check-nil --- ModData-backed bridge configuration: rate limits, payload cap, sensitive --- field policy. Defaults are baked in and only written to the ModData table --- the first time it's created. +-- Facade over ALL scalar constants/settings -- the single point of access, +-- so no other file should index RDB_Constants directly for a scalar value. +-- 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") RDB_Config = {} -local TAG = "RconDataBridge_Config" - -local DEFAULTS = { - maxPayloadBytes = RDB_Constants.MAX_PAYLOAD_BYTES, - 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 +-- PascalCase (sandbox-var convention, e.g. "MaxPayloadBytes") -> +-- SCREAMING_SNAKE_CASE (RDB_Constants convention, "MAX_PAYLOAD_BYTES"). +local function toConstantKey(key) + return (key:gsub("(%l)(%u)", "%1_%2")):upper() end function RDB_Config.get(key) - if not store then - RDB_Config.init() + local v = SandboxVars.RconDataBridge[key] + if v ~= nil then + return v end - return store[key] + return RDB_Constants[toConstantKey(key)] end -function RDB_Config.set(key, value) - if not store then - RDB_Config.init() - end - - 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 + return { + maxPayloadBytes = RDB_Config.get("MaxPayloadBytes"), + rateLimitMaxRequests = RDB_Config.get("RateLimitMaxRequests"), + rateLimitWindowSeconds = RDB_Config.get("RateLimitWindowSeconds"), + } end diff --git a/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_Idempotency.lua b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_Idempotency.lua index a814e4e..9ac9d38 100644 --- a/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_Idempotency.lua +++ b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_Idempotency.lua @@ -2,14 +2,15 @@ -- the original response instead of re-executing the op. -- Persisted via ModData so it survives restarts. -- --- Entries expire after RDB_Constants.IDEMPOTENCY_TTL_MS (60s by default): --- a request id is only idempotent for retries within that window, not --- forever. Without a TTL, a client that reuses a fixed id for routine --- polling would get the first-ever response back on every call, since a --- low-traffic server would rarely reach MAX_ENTRIES and trigger the --- count-based eviction below. +-- Entries expire after RDB_Config.get("IdempotencyTtlSeconds") (60s by +-- default, admin-tunable via sandbox-options.txt): a request id is only +-- idempotent for retries within that window, not forever. Without a TTL, a +-- client that reuses a fixed id for routine polling would get the +-- first-ever response back on every call, since a low-traffic server would +-- rarely reach MAX_ENTRIES and trigger the count-based eviction below. require("RconDataBridge.RDB_Constants") +require("RconDataBridge.RDB_Config") require("RconDataBridge.RDB_Log") RDB_Idempotency = {} @@ -27,7 +28,8 @@ local function ensureStore() end 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 function RDB_Idempotency.has(id) diff --git a/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_PlayerSnapshot.lua b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_PlayerSnapshot.lua index 29d3bd7..2d6db0a 100644 --- a/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_PlayerSnapshot.lua +++ b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_PlayerSnapshot.lua @@ -14,6 +14,7 @@ require("RconDataBridge.RDB_Constants") require("RconDataBridge.RDB_Log") require("RconDataBridge.RDB_Json") require("RconDataBridge.RDB_OptionsRegistry") +require("RconDataBridge.RDB_Config") RDB_PlayerSnapshot = {} @@ -78,8 +79,8 @@ function RDB_PlayerSnapshot.publishStatsOption(id) return end - local optionName = RDB_Constants.PLAYER_STATS_PREFIX .. id:gsub("[^%w_]", "_") - RDB_OptionsRegistry.register(optionName, "{}", RDB_Constants.OPTION_MAX_LENGTH) + local optionName = RDB_Config.get("PlayerStatsPrefix") .. id:gsub("[^%w_]", "_") + RDB_OptionsRegistry.register(optionName, "{}", RDB_Config.get("OptionMaxLength")) RDB_OptionsRegistry.set(optionName, RDB_Json.encode(snapshot)) end @@ -133,7 +134,7 @@ local initialized = false local function onTick() tickCounter = tickCounter + 1 - if tickCounter < RDB_Constants.POLL_EVERY_TICKS then + if tickCounter < RDB_Config.get("PollEveryTicks") then return 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 5ea442c..e46296f 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 @@ -7,6 +7,7 @@ require("RconDataBridge.RDB_Json") require("RconDataBridge.RDB_Base64") require("RconDataBridge.RDB_Log") require("RconDataBridge.RDB_OptionsRegistry") +require("RconDataBridge.RDB_Config") require("RconDataBridge.RDB_Security") require("RconDataBridge.RDB_Idempotency") require("RconDataBridge.RDB_AuditLog") @@ -19,7 +20,7 @@ local tickCounter = 0 local function buildOkResponse(id, data) return { - v = RDB_Constants.PROTOCOL_VERSION, + v = RDB_Config.get("ProtocolVersion"), id = id, ok = true, data = data or {} @@ -28,7 +29,7 @@ end local function buildErrorResponse(id, code, message) return { - v = RDB_Constants.PROTOCOL_VERSION, + v = RDB_Config.get("ProtocolVersion"), id = id, ok = false, error = { code = code, message = message }, @@ -142,7 +143,7 @@ end local function onTick() tickCounter = tickCounter + 1 - if tickCounter < RDB_Constants.POLL_EVERY_TICKS then + if tickCounter < RDB_Config.get("PollEveryTicks") then return end tickCounter = 0 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 ba1ec76..2a2fea1 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 @@ -11,7 +11,7 @@ require("RconDataBridge.RDB_Config") RDB_Security = {} 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" end @@ -24,7 +24,7 @@ function RDB_Security.validateEnvelope(obj) if type(obj) ~= "table" then return false, RDB_Constants.ERROR_CODES.SCHEMA_ERROR, "Request must be a JSON object" 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" end if type(obj.id) ~= "string" or #obj.id == 0 or #obj.id > 128 then @@ -46,7 +46,7 @@ local requestTimestamps = {} -- and getRateLimitStatus (which only observes). local function pruneAndCount() local now = getTimestampMs() - local windowMs = RDB_Config.get("rateLimitWindowSeconds") * 1000 + local windowMs = RDB_Config.get("RateLimitWindowSeconds") * 1000 local kept = {} for _, ts in ipairs(requestTimestamps) do @@ -60,7 +60,7 @@ local function pruneAndCount() end function RDB_Security.checkRateLimit() - local maxRequests = RDB_Config.get("rateLimitMaxRequests") + local maxRequests = RDB_Config.get("RateLimitMaxRequests") if pruneAndCount() >= maxRequests then return false, RDB_Constants.ERROR_CODES.RATE_LIMITED, "Too many requests, slow down" @@ -73,7 +73,7 @@ end function RDB_Security.getRateLimitStatus() return { currentWindowRequests = pruneAndCount(), - maxRequests = RDB_Config.get("rateLimitMaxRequests"), - windowSeconds = RDB_Config.get("rateLimitWindowSeconds"), + maxRequests = RDB_Config.get("RateLimitMaxRequests"), + windowSeconds = RDB_Config.get("RateLimitWindowSeconds"), } end diff --git a/Contents/mods/RconDataBridge/common/media/lua/shared/RconDataBridge/RDB_Constants.lua b/Contents/mods/RconDataBridge/common/media/lua/shared/RconDataBridge/RDB_Constants.lua index 7377467..696331a 100644 --- a/Contents/mods/RconDataBridge/common/media/lua/shared/RconDataBridge/RDB_Constants.lua +++ b/Contents/mods/RconDataBridge/common/media/lua/shared/RconDataBridge/RDB_Constants.lua @@ -6,8 +6,6 @@ RDB_Constants = {} -RDB_Constants.DEBUG = true - RDB_Constants.PROTOCOL_VERSION = 1 RDB_Constants.OPT = { @@ -22,16 +20,6 @@ RDB_Constants.PLAYER_STATS_PREFIX = "RconDataBridge_PlayerStats_" RDB_Constants.MAX_PAYLOAD_BYTES = 4096 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 = { SCHEMA_ERROR = "SCHEMA_ERROR", diff --git a/Contents/mods/RconDataBridge/common/media/lua/shared/Translate/EN/Sandbox.json b/Contents/mods/RconDataBridge/common/media/lua/shared/Translate/EN/Sandbox.json new file mode 100644 index 0000000..7e095f0 --- /dev/null +++ b/Contents/mods/RconDataBridge/common/media/lua/shared/Translate/EN/Sandbox.json @@ -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." +} diff --git a/Contents/mods/RconDataBridge/common/media/sandbox-options.txt b/Contents/mods/RconDataBridge/common/media/sandbox-options.txt index e69de29..e41fccf 100644 --- a/Contents/mods/RconDataBridge/common/media/sandbox-options.txt +++ b/Contents/mods/RconDataBridge/common/media/sandbox-options.txt @@ -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, +}