From 96b8e6263ee1c519cdb99aeaae0d163f3f629460 Mon Sep 17 00:00:00 2001 From: Overlord Date: Wed, 26 Aug 2026 14:43:16 +0200 Subject: [PATCH] Add core modules for RCON Data Bridge: audit log, Base64 codec, JSON parser, config management, logging, and options registry --- .../server/RconDataBridge/RDB_AuditLog.lua | 48 +++ .../lua/server/RconDataBridge/RDB_Config.lua | 45 ++ .../RconDataBridge/RDB_OptionsRegistry.lua | 48 +++ .../lua/shared/RconDataBridge/RDB_Base64.lua | 108 +++++ .../shared/RconDataBridge/RDB_Constants.lua | 35 ++ .../lua/shared/RconDataBridge/RDB_Json.lua | 392 ++++++++++++++++++ .../lua/shared/RconDataBridge/RDB_Log.lua | 15 + .../common/media/sandbox-options.txt | 0 8 files changed, 691 insertions(+) create mode 100644 Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_AuditLog.lua create mode 100644 Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_Config.lua create mode 100644 Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_OptionsRegistry.lua create mode 100644 Contents/mods/RconDataBridge/common/media/lua/shared/RconDataBridge/RDB_Base64.lua create mode 100644 Contents/mods/RconDataBridge/common/media/lua/shared/RconDataBridge/RDB_Constants.lua create mode 100644 Contents/mods/RconDataBridge/common/media/lua/shared/RconDataBridge/RDB_Json.lua create mode 100644 Contents/mods/RconDataBridge/common/media/lua/shared/RconDataBridge/RDB_Log.lua create mode 100644 Contents/mods/RconDataBridge/common/media/sandbox-options.txt diff --git a/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_AuditLog.lua b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_AuditLog.lua new file mode 100644 index 0000000..66a8012 --- /dev/null +++ b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_AuditLog.lua @@ -0,0 +1,48 @@ +-- Structured audit log: every remote action is logged with its request id, +-- operation, result, and timestamp, including rejected requests +-- (bad payload, unknown op, rate-limited, etc). Persisted as a bounded +-- ring buffer via ModData, plus a console line for live tailing. + +require("RconDataBridge.RDB_Log") + +RDB_AuditLog = {} + +local TAG = "RconDataBridge_AuditLog" +local MAX_ENTRIES = 500 + +local store + +local function ensureStore() + if not store then + store = ModData.getOrCreate(TAG) + if store.entries == nil then + store.entries = {} + store.nextIndex = 1 + end + end + + return store +end + +-- entry: { id, op, ok, code, ts } +function RDB_AuditLog.record(entry) + local s = ensureStore() + entry.ts = entry.ts or getTimestampMs() + if #s.entries < MAX_ENTRIES then + s.entries[#s.entries + 1] = entry + else + s.entries[s.nextIndex] = entry + s.nextIndex = (s.nextIndex % MAX_ENTRIES) + 1 + end + + local resultText = entry.ok and "ok" or ("error:" .. tostring(entry.code)) + RDB_Log.info( + "audit id=" .. tostring(entry.id) .. + " op=" .. tostring(entry.op) .. + " result=" .. resultText + ) +end + +function RDB_AuditLog.getEntries() + return ensureStore().entries +end 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 new file mode 100644 index 0000000..a8b2cd8 --- /dev/null +++ b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_Config.lua @@ -0,0 +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. + +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 +end + +function RDB_Config.get(key) + if not store then + RDB_Config.init() + end + + return store[key] +end + +function RDB_Config.set(key, value) + if not store then + RDB_Config.init() + end + + store[key] = value +end diff --git a/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_OptionsRegistry.lua b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_OptionsRegistry.lua new file mode 100644 index 0000000..f804cc8 --- /dev/null +++ b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_OptionsRegistry.lua @@ -0,0 +1,48 @@ +-- Thin wrapper around zombie.network.ServerOptions. +-- +-- Registration must do two things for an option to be both settable (via +-- changeoption) and visible (via showoptions): +-- 1. ServerOptions.instance:addOption(...) -- makes it settable/gettable +-- 2. ServerOptions.instance:getPublicOptions():add(name) -- makes it visible +-- addOption alone is not enough: showoptions reads a separate publicOptions +-- list that addOption never touches. +-- +-- Registration is idempotent: Events.OnServerStarted fires more than once per +-- boot. A getOptionByName pre-check alone isn't sufficient to suppress the +-- resulting duplicate-name IllegalArgumentException from addOption (observed +-- in practice even with the pre-check in place, likely a timing/ordering +-- quirk between the two OnServerStarted firings); addOption is wrapped in +-- pcall as a second, authoritative line of defense. Runtime state ends up +-- correct either way. + +require("RconDataBridge.RDB_Log") + +RDB_OptionsRegistry = {} + +function RDB_OptionsRegistry.register(name, defaultValue, maxLength) + local so = ServerOptions.instance + if so:getOptionByName(name) ~= nil then + return + end + + local opt = TextServerOption.new(so, name, defaultValue, maxLength) + local ok = pcall(function() so:addOption(opt) end) + if not ok then + return + end + + so:getPublicOptions():add(name) + RDB_Log.info("registered option " .. name) +end + +function RDB_OptionsRegistry.get(name) + return ServerOptions.instance:getOption(name) +end + +-- Uses putOption (in-memory only), not changeOption: changeOption also does a +-- full saveServerTextFile disk write on every call, which is fine for a real +-- RCON-initiated config change but wasteful for our own frequent internal +-- publishes (Response, WorldStats, etc. are runtime state, not persisted config). +function RDB_OptionsRegistry.set(name, value) + ServerOptions.instance:putOption(name, value) +end diff --git a/Contents/mods/RconDataBridge/common/media/lua/shared/RconDataBridge/RDB_Base64.lua b/Contents/mods/RconDataBridge/common/media/lua/shared/RconDataBridge/RDB_Base64.lua new file mode 100644 index 0000000..22f8e85 --- /dev/null +++ b/Contents/mods/RconDataBridge/common/media/lua/shared/RconDataBridge/RDB_Base64.lua @@ -0,0 +1,108 @@ +---@diagnostic disable: param-type-mismatch, need-check-nil + +-- Minimal base64 encode/decode. Required for the request channel: RCON's +-- command-line tokenizer (CommandBase's constructor) strips every literal +-- double-quote character from every changeoption argument, confirmed by +-- decompiling CommandBase, and confirmed empirically (a raw JSON payload +-- comes out with every " removed, corrupting it beyond recovery). No quoting +-- strategy survives this since the strip is unconditional and happens after +-- tokenization regardless of which token form matched. Base64 has no quote +-- characters in its alphabet, so it survives changeoption intact. +-- +-- Only the request channel needs this: responses are written via +-- ServerOptions:putOption (a direct Lua/Java call, not the RCON text-command +-- path) and read via showoptions, neither of which goes through the +-- tokenizer, so they carry raw JSON unmolested. + +-- TODO: does output still base64 encode? otherwise the API is weird (base64 json in, json out) + +RDB_Base64 = {} + +local ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" + +local decodeTable +local function buildDecodeTable() + decodeTable = {} + for i = 1, #ALPHABET do + decodeTable[ALPHABET:sub(i, i)] = i - 1 + end +end + +function RDB_Base64.encode(data) + local out = {} + local len = #data + local i = 1 + while i <= len do + local b1, b2, b3 = data:byte(i, i + 2) + b2 = b2 or 0 + b3 = b3 or 0 + + local n = b1 * 65536 + b2 * 256 + b3 + + local c1 = math.floor(n / 262144) % 64 + local c2 = math.floor(n / 4096) % 64 + local c3 = math.floor(n / 64) % 64 + local c4 = n % 64 + + out[#out + 1] = ALPHABET:sub(c1 + 1, c1 + 1) + out[#out + 1] = ALPHABET:sub(c2 + 1, c2 + 1) + out[#out + 1] = (i + 1 <= len) and ALPHABET:sub(c3 + 1, c3 + 1) or "=" + out[#out + 1] = (i + 2 <= len) and ALPHABET:sub(c4 + 1, c4 + 1) or "=" + + i = i + 3 + end + return table.concat(out) +end + +function RDB_Base64.decode(str) + if not decodeTable then + buildDecodeTable() + end + + str = str:gsub("=", "") + local out = {} + local i = 1 + local len = #str + while i <= len do + local c1 = decodeTable[str:sub(i, i)] + local c2 = decodeTable[str:sub(i + 1, i + 1)] + local c3str = str:sub(i + 2, i + 2) + local c4str = str:sub(i + 3, i + 3) + local c3 = c3str ~= "" and decodeTable[c3str] or nil + local c4 = c4str ~= "" and decodeTable[c4str] or nil + + if c1 == nil or c2 == nil then + return nil, "invalid base64 input" + end + + local n = c1 * 262144 + c2 * 4096 + (c3 or 0) * 64 + (c4 or 0) + + out[#out + 1] = string.char(math.floor(n / 65536) % 256) + if c3 ~= nil then + out[#out + 1] = string.char(math.floor(n / 256) % 256) + end + if c4 ~= nil then + out[#out + 1] = string.char(n % 256) + end + + i = i + 4 + end + return table.concat(out) +end + +function RDB_Base64.selfTest() + local samples = { "", "a", "ab", "abc", '{"v":1,"id":"t1","op":"echo","args":{"x":1}}' } + + for _, s in ipairs(samples) do + local encoded = RDB_Base64.encode(s) + local decoded, err = RDB_Base64.decode(encoded) + if err then + return false, "decode error for " .. s .. ": " .. err + end + if decoded ~= s then + return false, "round-trip mismatch for " .. s + end + end + + return true +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 new file mode 100644 index 0000000..85e3220 --- /dev/null +++ b/Contents/mods/RconDataBridge/common/media/lua/shared/RconDataBridge/RDB_Constants.lua @@ -0,0 +1,35 @@ +-- Shared constants: option names, protocol version, limits. +-- +-- Option names use underscores, not dots: RCON's changeoption command validates +-- the option-name argument against the regex \w+ (word characters only), so a +-- dotted name is silently rejected before it ever reaches the option lookup. + +RDB_Constants = {} + +RDB_Constants.DEBUG = true + +RDB_Constants.PROTOCOL_VERSION = 1 + +RDB_Constants.OPT = { + PROTOCOL_VERSION = "RconDataBridge_ProtocolVersion", + WORLD_STATS = "RconDataBridge_WorldStats", + REQUEST = "RconDataBridge_Request", + RESPONSE = "RconDataBridge_Response", + LAST_RESPONSE = "RconDataBridge_LastResponse", +} + +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 + +RDB_Constants.ERROR_CODES = { + SCHEMA_ERROR = "SCHEMA_ERROR", + PAYLOAD_TOO_LARGE = "PAYLOAD_TOO_LARGE", + OP_NOT_ALLOWED = "OP_NOT_ALLOWED", + RATE_LIMITED = "RATE_LIMITED", + PLAYER_NOT_FOUND = "PLAYER_NOT_FOUND", + SAVE_FAILED = "SAVE_FAILED", + INTERNAL_ERROR = "INTERNAL_ERROR", +} diff --git a/Contents/mods/RconDataBridge/common/media/lua/shared/RconDataBridge/RDB_Json.lua b/Contents/mods/RconDataBridge/common/media/lua/shared/RconDataBridge/RDB_Json.lua new file mode 100644 index 0000000..9761074 --- /dev/null +++ b/Contents/mods/RconDataBridge/common/media/lua/shared/RconDataBridge/RDB_Json.lua @@ -0,0 +1,392 @@ +---@diagnostic disable: need-check-nil + +-- Dependency-free JSON encode/decode. No JSON library exists anywhere in the +-- PZ Lua API (checked exhaustively against the umbrella + zdoc-lua stub +-- trees), so the mod brings its own minimal one. +-- +-- encode() always produces compact output (no whitespace). This is required, +-- not just stylistic: RCON's command tokenizer only treats double-quoted +-- spans as a single argument, so a payload containing a literal space would +-- be split into multiple arguments and fail parsing. + +RDB_Json = {} + +local ESCAPES = { + ['"'] = '\\"', + ['\\'] = '\\\\', + ['\b'] = '\\b', + ['\f'] = '\\f', + ['\n'] = '\\n', + ['\r'] = '\\r', + ['\t'] = '\\t', +} + +local function encodeString(s) + local out = { '"' } + + for i = 1, #s do + local c = s:sub(i, i) + local esc = ESCAPES[c] + if esc then + out[#out + 1] = esc + elseif c:byte() < 0x20 then + out[#out + 1] = string.format('\\u%04x', c:byte()) + else + out[#out + 1] = c + end + end + + out[#out + 1] = '"' + + return table.concat(out) +end + +local function isArray(t) + local n = 0 + + for _ in pairs(t) do + n = n + 1 + end + + if n == 0 then + return true -- empty table encodes as [] ; use {} explicitly for an empty object + end + + for i = 1, n do + if t[i] == nil then + return false + end + end + + return true +end + +local encodeValue + +local function encodeArray(t) + local parts = {} + + for i = 1, #t do + parts[i] = encodeValue(t[i]) + end + + return "[" .. table.concat(parts, ",") .. "]" +end + +local function encodeObject(t) + local parts = {} + + for k, v in pairs(t) do + parts[#parts + 1] = encodeString(tostring(k)) .. ":" .. encodeValue(v) + end + + return "{" .. table.concat(parts, ",") .. "}" +end + +encodeValue = function(v) + local t = type(v) + + if v == nil then + return "null" + elseif t == "boolean" then + return v and "true" or "false" + elseif t == "number" then + return tostring(v) + elseif t == "string" then + return encodeString(v) + elseif t == "table" then + if isArray(v) then + return encodeArray(v) + end + return encodeObject(v) + end + + error("RDB_Json.encode: unsupported type " .. t) +end + +function RDB_Json.encode(value) + return encodeValue(value) +end + +-- Decoder: simple recursive-descent parser over a string + cursor index. + +local function newParser(str) + return { str = str, pos = 1, len = #str } +end + +local function peek(p) + if p.pos > p.len then + return nil + end + + return p.str:sub(p.pos, p.pos) +end + +local function skipWhitespace(p) + while p.pos <= p.len do + local c = p.str:sub(p.pos, p.pos) + + if c == " " or c == "\t" or c == "\n" or c == "\r" then + p.pos = p.pos + 1 + else + break + end + end +end + +local parseValue + +local function parseLiteral(p, literal, value) + if p.str:sub(p.pos, p.pos + #literal - 1) ~= literal then + return nil, "expected " .. literal + end + p.pos = p.pos + #literal + return value +end + +local function parseString(p) + if peek(p) ~= '"' then + return nil, "expected string" + end + + p.pos = p.pos + 1 + local out = {} + + while true do + if p.pos > p.len then + return nil, "unterminated string" + end + + local c = p.str:sub(p.pos, p.pos) + + if c == '"' then + p.pos = p.pos + 1 + return table.concat(out) + elseif c == "\\" then + local nextC = p.str:sub(p.pos + 1, p.pos + 1) + + if nextC == "u" then + local hex = p.str:sub(p.pos + 2, p.pos + 5) + local code = tonumber(hex, 16) + if not code then + return nil, "invalid \\u escape" + end + + out[#out + 1] = string.char(code < 256 and code or 63) + p.pos = p.pos + 6 + else + local unescaped = ({ + ['"'] = '"', ['\\'] = '\\', ['/'] = '/', + b = '\b', f = '\f', n = '\n', r = '\r', t = '\t', + })[nextC] + + if not unescaped then + return nil, "invalid escape \\" .. tostring(nextC) + end + + out[#out + 1] = unescaped + p.pos = p.pos + 2 + end + else + out[#out + 1] = c + p.pos = p.pos + 1 + end + end +end + +local function parseNumber(p) + local start = p.pos + + if peek(p) == "-" then + p.pos = p.pos + 1 + end + + while peek(p) and peek(p):match("%d") do + p.pos = p.pos + 1 + end + + if peek(p) == "." then + p.pos = p.pos + 1 + + while peek(p) and peek(p):match("%d") do + p.pos = p.pos + 1 + end + end + + if peek(p) == "e" or peek(p) == "E" then + p.pos = p.pos + 1 + + if peek(p) == "+" or peek(p) == "-" then + p.pos = p.pos + 1 + end + + while peek(p) and peek(p):match("%d") do + p.pos = p.pos + 1 + end + end + + local numStr = p.str:sub(start, p.pos - 1) + local n = tonumber(numStr) + if not n then + return nil, "invalid number" + end + + return n +end + +local function parseArray(p) + p.pos = p.pos + 1 -- consume [ + local out = {} + skipWhitespace(p) + + if peek(p) == "]" then + p.pos = p.pos + 1 + return out + end + + local i = 1 + while true do + skipWhitespace(p) + + local v, err = parseValue(p) + if err then + return nil, err + end + + out[i] = v + i = i + 1 + skipWhitespace(p) + local c = peek(p) + + if c == "," then + p.pos = p.pos + 1 + elseif c == "]" then + p.pos = p.pos + 1 + return out + else + return nil, "expected , or ] in array" + end + end +end + +local function parseObject(p) + p.pos = p.pos + 1 -- consume { + local out = {} + skipWhitespace(p) + + if peek(p) == "}" then + p.pos = p.pos + 1 + return out + end + + while true do + skipWhitespace(p) + + local key, err = parseString(p) + if err then + return nil, err + end + + skipWhitespace(p) + + if peek(p) ~= ":" then + return nil, "expected : in object" + end + + p.pos = p.pos + 1 + skipWhitespace(p) + + local value + value, err = parseValue(p) + if err then + return nil, err + end + + out[key] = value + skipWhitespace(p) + + local c = peek(p) + if c == "," then + p.pos = p.pos + 1 + elseif c == "}" then + p.pos = p.pos + 1 + return out + else + return nil, "expected , or } in object" + end + end +end + +parseValue = function(p) + skipWhitespace(p) + + local c = peek(p) + if c == nil then + return nil, "unexpected end of input" + elseif c == '"' then + return parseString(p) + elseif c == "{" then + return parseObject(p) + elseif c == "[" then + return parseArray(p) + elseif c == "t" then + return parseLiteral(p, "true", true) + elseif c == "f" then + return parseLiteral(p, "false", false) + elseif c == "n" then + return parseLiteral(p, "null", nil) + elseif c == "-" or c:match("%d") then + return parseNumber(p) + end + + return nil, "unexpected character " .. c +end + +-- Round-trips a nested table through encode/decode and checks the result +-- matches. Not exhaustive, just a smoke test that the codec isn't broken. +function RDB_Json.selfTest() + local sample = { + v = 1, + id = "t1", + ok = true, + nested = { a = 1, b = "two", c = { 1, 2, 3 } }, + empty = {}, + } + + local encoded = RDB_Json.encode(sample) + local decoded, err = RDB_Json.decode(encoded) + + if err then + return false, "decode error: " .. err + end + if decoded.v ~= 1 or decoded.id ~= "t1" or decoded.ok ~= true then + return false, "top-level mismatch" + end + if decoded.nested.a ~= 1 or decoded.nested.b ~= "two" then + return false, "nested mismatch" + end + if #decoded.nested.c ~= 3 or decoded.nested.c[2] ~= 2 then + return false, "array mismatch" + end + return true +end + +function RDB_Json.decode(str) + if type(str) ~= "string" then + return nil, "input is not a string" + end + + local p = newParser(str) + skipWhitespace(p) + local value, err = parseValue(p) + if err then + return nil, err + end + + skipWhitespace(p) + if p.pos <= p.len then + return nil, "trailing data after value" + end + + return value +end diff --git a/Contents/mods/RconDataBridge/common/media/lua/shared/RconDataBridge/RDB_Log.lua b/Contents/mods/RconDataBridge/common/media/lua/shared/RconDataBridge/RDB_Log.lua new file mode 100644 index 0000000..287f0d0 --- /dev/null +++ b/Contents/mods/RconDataBridge/common/media/lua/shared/RconDataBridge/RDB_Log.lua @@ -0,0 +1,15 @@ +RDB_Log = {} + +local PREFIX = "[RconDataBridge] " + +function RDB_Log.info(msg) + print(PREFIX .. "INFO: " .. tostring(msg)) +end + +function RDB_Log.warn(msg) + print(PREFIX .. "WARN: " .. tostring(msg)) +end + +function RDB_Log.error(msg) + print(PREFIX .. "ERROR: " .. tostring(msg)) +end diff --git a/Contents/mods/RconDataBridge/common/media/sandbox-options.txt b/Contents/mods/RconDataBridge/common/media/sandbox-options.txt new file mode 100644 index 0000000..e69de29