Add core modules for RCON Data Bridge: audit log, Base64 codec, JSON parser, config management, logging, and options registry
This commit is contained in:
@@ -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
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user