9 Commits

22 changed files with 1459 additions and 10 deletions

18
.gitignore vendored
View File

@@ -1,24 +1,26 @@
# ---> Lua
# Compiled Lua sources
.idea
pzlibs
.emmyrc.json
scripts
*.md
luac.out
# luarocks build files
*.src.rock
*.zip
*.tar.gz
# Object files
*.o
*.os
*.ko
*.obj
*.elf
# Precompiled Headers
*.gch
*.pch
# Libraries
*.lib
*.a
*.la
@@ -26,18 +28,14 @@ luac.out
*.def
*.exp
# Shared objects (inc. Windows DLLs)
*.dll
*.so
*.so.*
*.dylib
# Executables
*.exe
*.out
*.app
*.i*86
*.x86_64
*.hex

View File

@@ -0,0 +1,7 @@
name=RCON Data Bridge
id=RconDataBridge
description=A Project Zomboid server mod enabling richer RCON tooling and automation.
poster=poster.png
author=Overlord_303
modversion=0.1.0
versionMin=42.0.0

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

View File

@@ -0,0 +1,20 @@
-- Registration deferred to register(), called from RDB_Bootstrap's
-- onServerStarted, see RDB_OpWorldGetStats.lua.
RDB_OpPlayerGet = {}
function RDB_OpPlayerGet.register()
RDB_OpRegistry.register("get_player", {
argsSchema = { id = "string" },
handler = function(args)
local snapshot = RDB_PlayerSnapshot.get(args.id)
if not snapshot then
return false, RDB_Constants.ERROR_CODES.PLAYER_NOT_FOUND,
"No registered player matched the supplied id."
end
RDB_PlayerSnapshot.publishStatsOption(args.id)
return true, snapshot
end,
})
end

View File

@@ -0,0 +1,48 @@
-- Cursor-based pagination (spec: "Large responses use pagination or
-- cursor-based retrieval"): players are sorted by id, and the cursor is the
-- last id returned in the previous page.
--
-- Registration deferred to register(), called from RDB_Bootstrap's
-- onServerStarted -- see RDB_OpWorldGetStats.lua for why.
RDB_OpPlayerList = {}
local DEFAULT_LIMIT = 50
local MAX_LIMIT = 200
function RDB_OpPlayerList.register()
RDB_OpRegistry.register("list_players", {
handler = function(args)
local all = RDB_PlayerSnapshot.listAll()
local limit = DEFAULT_LIMIT
if type(args.limit) == "number" then
limit = math.min(math.max(1, math.floor(args.limit)), MAX_LIMIT)
end
local startIndex = 1
if type(args.cursor) == "string" and args.cursor ~= "" then
for i, snapshot in ipairs(all) do
if snapshot.id > args.cursor then
startIndex = i
break
end
startIndex = i + 1
end
end
local page = {}
local endIndex = math.min(startIndex + limit - 1, #all)
for i = startIndex, endIndex do
page[#page + 1] = all[i]
end
local nextCursor = nil
if endIndex < #all then
nextCursor = page[#page].id
end
return true, { players = page, nextCursor = nextCursor }
end,
})
end

View File

@@ -0,0 +1,16 @@
-- Registration deferred to register(), called from RDB_Bootstrap's
-- onServerStarted, see RDB_OpWorldGetStats.lua.
RDB_OpServerSave = {}
function RDB_OpServerSave.register()
RDB_OpRegistry.register("save_server", {
handler = function(_)
local ok = pcall(function() GameWindow.save(true) end)
if not ok then
return false, RDB_Constants.ERROR_CODES.SAVE_FAILED, "World save failed."
end
return true, { triggered = true }
end,
})
end

View File

@@ -0,0 +1,17 @@
-- Registration is deferred to register(), called from RDB_Bootstrap's
-- registerOps() (itself called from onServerStarted), rather than run at
-- file top-level: PZ's require() is best-effort (warns and continues rather
-- than forcing a synchronous load), so RDB_OpRegistry may not be defined yet
-- if this file's top-level code ran immediately at auto-load time.
-- onServerStarted fires only after every file has auto-loaded, so it's safe
-- there.
RDB_OpWorldGetStats = {}
function RDB_OpWorldGetStats.register()
RDB_OpRegistry.register("get_world_stats", {
handler = function(_)
return true, RDB_WorldStats.collect()
end,
})
end

View File

@@ -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

View File

@@ -0,0 +1,73 @@
require("RconDataBridge.RDB_Constants")
require("RconDataBridge.RDB_Log")
require("RconDataBridge.RDB_Json")
require("RconDataBridge.RDB_Base64")
require("RconDataBridge.RDB_OptionsRegistry")
require("RconDataBridge.RDB_Config")
require("RconDataBridge.RDB_OpRegistry")
require("RconDataBridge.RDB_RequestPipeline")
require("RconDataBridge.RDB_WorldStats")
require("RconDataBridge.RDB_PlayerSnapshot")
require("RconDataBridge.Ops.RDB_OpWorldGetStats")
require("RconDataBridge.Ops.RDB_OpPlayerGet")
require("RconDataBridge.Ops.RDB_OpPlayerList")
require("RconDataBridge.Ops.RDB_OpServerSave")
local function runSelfTests()
if not RDB_Constants.DEBUG then
return
end
local ok, err = RDB_Json.selfTest()
if ok then
RDB_Log.info("RDB_Json self-test passed")
else
RDB_Log.error("RDB_Json self-test FAILED: " .. tostring(err))
end
local b64Ok, b64Err = RDB_Base64.selfTest()
if b64Ok then
RDB_Log.info("RDB_Base64 self-test passed")
else
RDB_Log.error("RDB_Base64 self-test FAILED: " .. tostring(b64Err))
end
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)
end
local function registerOps()
RDB_OpWorldGetStats.register()
RDB_OpPlayerGet.register()
RDB_OpPlayerList.register()
RDB_OpServerSave.register()
end
local function publishProtocolVersion()
local json = RDB_Json.encode({
version = RDB_Constants.PROTOCOL_VERSION,
ops = RDB_OpRegistry.listNames(),
})
RDB_OptionsRegistry.set(RDB_Constants.OPT.PROTOCOL_VERSION, json)
end
local function onServerStarted()
runSelfTests()
RDB_Config.init()
registerBaseOptions()
registerOps()
publishProtocolVersion()
RDB_RequestPipeline.init()
RDB_WorldStats.init()
RDB_PlayerSnapshot.init()
RDB_Log.info("bootstrap complete")
end
Events.OnServerStarted.Add(onServerStarted)

View File

@@ -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

View File

@@ -0,0 +1,84 @@
-- Processed request-id -> cached response, so a repeated request id replays
-- 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.
require("RconDataBridge.RDB_Constants")
require("RconDataBridge.RDB_Log")
RDB_Idempotency = {}
local TAG = "RconDataBridge_Idempotency"
local MAX_ENTRIES = 500
local store
local function ensureStore()
if not store then
store = ModData.getOrCreate(TAG)
end
return store
end
local function isExpired(entry)
return getTimestampMs() - (entry.ts or 0) >= RDB_Constants.IDEMPOTENCY_TTL_MS
end
function RDB_Idempotency.has(id)
local entry = ensureStore()[id]
return entry ~= nil and not isExpired(entry)
end
function RDB_Idempotency.get(id)
local entry = ensureStore()[id]
if not entry or isExpired(entry) then
return nil
end
return entry.response
end
-- Sweeps expired entries first (the common case that keeps the store small
-- under normal traffic), then falls back to trimming the oldest entries if
-- still over MAX_ENTRIES (a backstop against a burst of distinct ids within
-- a single TTL window).
local function prune(s)
for id, entry in pairs(s) do
if isExpired(entry) then
s[id] = nil
end
end
local n = 0
for _ in pairs(s) do
n = n + 1
end
if n <= MAX_ENTRIES then
return
end
local entries = {}
for id, entry in pairs(s) do
entries[#entries + 1] = { id = id, ts = entry.ts or 0 }
end
table.sort(entries, function(a, b) return a.ts < b.ts end)
local toRemove = n - MAX_ENTRIES
for i = 1, toRemove do
s[entries[i].id] = nil
end
end
function RDB_Idempotency.put(id, response)
local s = ensureStore()
s[id] = { response = response, ts = getTimestampMs() }
prune(s)
end

View File

@@ -0,0 +1,59 @@
-- Table-driven op registry. This is the allowlist: dispatch only ever looks
-- up a handler function by exact op name in this table -- there is no
-- dynamic _G[name]() call and no load()/loadstring() anywhere in this mod.
require("RconDataBridge.RDB_Constants")
RDB_OpRegistry = {}
local handlers = {}
-- def: { handler = function(args) -> ok, dataOrErrCode[, errMessage], argsSchema = {fieldName = "type", ...} }
function RDB_OpRegistry.register(name, def)
handlers[name] = def
end
function RDB_OpRegistry.isRegistered(name)
return handlers[name] ~= nil
end
function RDB_OpRegistry.listNames()
local names = {}
for name in pairs(handlers) do
names[#names + 1] = name
end
table.sort(names)
return names
end
-- Validates args against the op's declared schema (shallow: field presence +
-- Lua type of top-level fields), then dispatches. Returns:
-- ok, data on success
-- false, errCode, errMessage on failure (schema or handler-reported error)
function RDB_OpRegistry.dispatch(name, args)
local def = handlers[name]
if not def then
return false, RDB_Constants.ERROR_CODES.OP_NOT_ALLOWED, "Unknown or disallowed operation: " .. tostring(name)
end
args = args or {}
if def.argsSchema then
for field, expectedType in pairs(def.argsSchema) do
local value = args[field]
if value == nil then
return false, RDB_Constants.ERROR_CODES.SCHEMA_ERROR, "Missing required arg: " .. field
end
if type(value) ~= expectedType then
return false, RDB_Constants.ERROR_CODES.SCHEMA_ERROR,
"Arg " .. field .. " must be " .. expectedType
end
end
end
local ok, dataOrCode, message = def.handler(args)
if not ok then
return false, dataOrCode, message
end
return true, dataOrCode
end

View File

@@ -0,0 +1,59 @@
-- 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.
--
-- Every published value is base64-encoded here, uniformly, for both
-- directions: register()'s default and set()'s value both go through
-- RDB_Base64.encode before reaching ServerOptions. This isn't optional for
-- the request channel (changeoption strips every literal double-quote
-- character from its arguments, corrupting raw JSON and is applied to every
-- other option too so RCON clients deal with one rule, not request-only
-- encoding as a surprise exception. get() intentionally stays raw:
-- the request pipeline diffs the raw stored string for change detection
-- and needs to see it either way, decode failure included.
require("RconDataBridge.RDB_Log")
require("RconDataBridge.RDB_Base64")
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, RDB_Base64.encode(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, RDB_Base64.encode(value))
end

View File

@@ -0,0 +1,155 @@
-- Normalized player snapshots: id (username), online, characterId,
-- lastSeenAt, stats{hoursSurvived, zombiesKilled}.
-- Persisted via ModData so offline lookups work, there is no
-- engine-provided offline-player registry API (checked exhaustively
-- against the stub trees), so this snapshot store is the only source
-- of truth for offline players..
--
-- Connect/disconnect detection: no server-side connect/disconnect event was
-- found in the stub trees, so this polls getOnlinePlayers() on the same
-- tick throttle as the request pipeline and diffs against the last-known
-- online set.
require("RconDataBridge.RDB_Constants")
require("RconDataBridge.RDB_Log")
require("RconDataBridge.RDB_Json")
require("RconDataBridge.RDB_OptionsRegistry")
RDB_PlayerSnapshot = {}
local TAG = "RconDataBridge_PlayerSnapshots"
local store
local function ensureStore()
if not store then
store = ModData.getOrCreate(TAG)
end
return store
end
local function buildStatsFromPlayer(player)
return {
hoursSurvived = player:getHoursSurvived(),
zombiesKilled = player:getZombieKills(),
}
end
-- Refreshes (or creates) the snapshot for an online IsoPlayer.
function RDB_PlayerSnapshot.refresh(player)
local username = player:getUsername()
local s = ensureStore()
s[username] = {
id = username,
online = true,
characterId = player:getOnlineID(),
lastSeenAt = getTimestampMs(),
stats = buildStatsFromPlayer(player),
}
end
-- Marks a previously-online player as offline, keeping their last known
-- stats and characterId.
function RDB_PlayerSnapshot.markOffline(username)
local s = ensureStore()
local existing = s[username]
if not existing then
return
end
existing.online = false
existing.lastSeenAt = getTimestampMs()
end
function RDB_PlayerSnapshot.get(id)
return ensureStore()[id]
end
-- RconDataBridge_PlayerStats_<ID> options are registered lazily, only for
-- players actually queried, not eagerly for the whole roster: ServerOptions
-- has no observed removeOption, so eager per-player registration would
-- grow unboundedly and bloat every RCON client's showoptions output
-- for a server with any real player history.
-- Not restored across a restart, it's simply re-created the next time
-- that player is queried.
function RDB_PlayerSnapshot.publishStatsOption(id)
local snapshot = ensureStore()[id]
if not snapshot then
return
end
local optionName = RDB_Constants.PLAYER_STATS_PREFIX .. id:gsub("[^%w_]", "_")
RDB_OptionsRegistry.register(optionName, "{}", RDB_Constants.OPTION_MAX_LENGTH)
RDB_OptionsRegistry.set(optionName, RDB_Json.encode(snapshot))
end
function RDB_PlayerSnapshot.listAll()
local out = {}
for _, snapshot in pairs(ensureStore()) do
out[#out + 1] = snapshot
end
table.sort(out, function(a, b) return a.id < b.id end)
return out
end
-- Death hook: refresh immediately so the final stats (e.g. zombiesKilled at
-- time of death) are captured rather than waiting for the next periodic
-- sweep.
function RDB_PlayerSnapshot.onCharacterDeath(character)
if character == nil or character.getUsername == nil then
return -- not a player character
end
local ok = pcall(RDB_PlayerSnapshot.refresh, character)
if not ok then
return
end
end
local knownOnline = {}
local function pollOnlineRoster()
local currentOnline = {}
local players = getOnlinePlayers()
for i = 0, players:size() - 1 do
local player = players:get(i)
local username = player:getUsername()
currentOnline[username] = true
RDB_PlayerSnapshot.refresh(player)
end
for username in pairs(knownOnline) do
if not currentOnline[username] then
RDB_PlayerSnapshot.markOffline(username)
end
end
knownOnline = currentOnline
end
local tickCounter = 0
local initialized = false
local function onTick()
tickCounter = tickCounter + 1
if tickCounter < RDB_Constants.POLL_EVERY_TICKS then
return
end
tickCounter = 0
local ok, err = pcall(pollOnlineRoster)
if not ok then
RDB_Log.error("error polling online roster: " .. tostring(err))
end
end
function RDB_PlayerSnapshot.init()
if initialized then
return
end
initialized = true
Events.OnTick.Add(onTick)
Events.OnCharacterDeath.Add(RDB_PlayerSnapshot.onCharacterDeath)
end

View File

@@ -0,0 +1,151 @@
-- Polls RconDataBridge_Request for changes (there is no "option changed"
-- event in the PZ Lua API) and runs the full request/response pipeline.
-- Every outcome, including every rejection, is audited.
require("RconDataBridge.RDB_Constants")
require("RconDataBridge.RDB_Json")
require("RconDataBridge.RDB_Base64")
require("RconDataBridge.RDB_Log")
require("RconDataBridge.RDB_OptionsRegistry")
require("RconDataBridge.RDB_Security")
require("RconDataBridge.RDB_Idempotency")
require("RconDataBridge.RDB_AuditLog")
require("RconDataBridge.RDB_OpRegistry")
RDB_RequestPipeline = {}
local lastSeenRaw = nil
local tickCounter = 0
local function buildOkResponse(id, data)
return {
v = RDB_Constants.PROTOCOL_VERSION,
id = id,
ok = true,
data = data or {}
}
end
local function buildErrorResponse(id, code, message)
return {
v = RDB_Constants.PROTOCOL_VERSION,
id = id,
ok = false,
error = { code = code, message = message },
}
end
local function publishResponse(response)
RDB_OptionsRegistry.set(RDB_Constants.OPT.RESPONSE, RDB_Json.encode(response))
RDB_OptionsRegistry.set(RDB_Constants.OPT.LAST_RESPONSE, tostring(getTimestampMs()))
end
local function audit(id, op, ok, code)
RDB_AuditLog.record({ id = id, op = op, ok = ok, code = code })
end
-- Handles one raw request string end to end. raw is base64: RCON's
-- changeoption command strips every literal double-quote character from
-- every argument (see RESEARCH_LOG.md), so a client can't send raw JSON --
-- base64 has no quote characters in its alphabet and survives intact.
local function processRequest(rawBase64)
local raw, b64Err = RDB_Base64.decode(rawBase64)
if b64Err then
local response = buildErrorResponse("", RDB_Constants.ERROR_CODES.SCHEMA_ERROR, "Invalid base64: " .. b64Err)
publishResponse(response)
audit("", "", false, RDB_Constants.ERROR_CODES.SCHEMA_ERROR)
return
end
local sizeOk, sizeCode, sizeMsg = RDB_Security.checkPayloadSize(raw)
if not sizeOk then
local response = buildErrorResponse("", sizeCode, sizeMsg)
publishResponse(response)
audit("", "", false, sizeCode)
return
end
local decoded, decodeErr = RDB_Json.decode(raw)
if decodeErr then
local response = buildErrorResponse("", RDB_Constants.ERROR_CODES.SCHEMA_ERROR, "Invalid JSON: " .. decodeErr)
publishResponse(response)
audit("", "", false, RDB_Constants.ERROR_CODES.SCHEMA_ERROR)
return
end
-- Empty object ({}) is the option's rest state, not a real request.
if type(decoded) == "table" and decoded.v == nil and decoded.id == nil and decoded.op == nil then
return
end
local envelopeOk, envelopeCode, envelopeMsg = RDB_Security.validateEnvelope(decoded)
if not envelopeOk then
local id = (type(decoded) == "table" and type(decoded.id) == "string") and decoded.id or ""
local response = buildErrorResponse(id, envelopeCode, envelopeMsg)
publishResponse(response)
audit(id, (type(decoded) == "table" and decoded.op) or "", false, envelopeCode)
return
end
local id, op, args = decoded.id, decoded.op, decoded.args
if RDB_Idempotency.has(id) then
local cached = RDB_Idempotency.get(id)
publishResponse(cached)
audit(id, op, cached.ok, cached.ok and nil or (cached.error and cached.error.code))
return
end
local rateOk, rateCode, rateMsg = RDB_Security.checkRateLimit()
if not rateOk then
local response = buildErrorResponse(id, rateCode, rateMsg)
publishResponse(response)
audit(id, op, false, rateCode)
return
end
local dispatchOk, dataOrCode, message = RDB_OpRegistry.dispatch(op, args)
local response
if dispatchOk then
response = buildOkResponse(id, dataOrCode)
else
response = buildErrorResponse(id, dataOrCode, message)
end
RDB_Idempotency.put(id, response)
publishResponse(response)
audit(id, op, dispatchOk, dispatchOk and nil or dataOrCode)
end
local function onTick()
tickCounter = tickCounter + 1
if tickCounter < RDB_Constants.POLL_EVERY_TICKS then
return
end
tickCounter = 0
local raw = RDB_OptionsRegistry.get(RDB_Constants.OPT.REQUEST)
if raw == lastSeenRaw then
return
end
lastSeenRaw = raw
local ok, err = pcall(processRequest, raw)
if not ok then
RDB_Log.error("unhandled error processing request: " .. tostring(err))
end
end
local initialized = false
-- Guarded: Events.OnServerStarted fires more than once per boot (see
-- RESEARCH_LOG.md), and Events.OnTick.Add does not deduplicate identical
-- listeners, so calling this twice would otherwise double-register onTick.
function RDB_RequestPipeline.init()
if initialized then
return
end
initialized = true
lastSeenRaw = RDB_OptionsRegistry.get(RDB_Constants.OPT.REQUEST)
Events.OnTick.Add(onTick)
end

View File

@@ -0,0 +1,63 @@
-- Payload size cap, envelope schema validation, and rate limiting.
--
-- Rate limiting is necessarily global (not per-caller): RCON requests carry
-- no per-connection identity in this transport -- every RCON client shares
-- the same RconDataBridge_Request mailbox. The window is an in-memory sliding
-- log; it does not need to survive a restart.
require("RconDataBridge.RDB_Constants")
require("RconDataBridge.RDB_Config")
RDB_Security = {}
function RDB_Security.checkPayloadSize(raw)
if #raw > RDB_Config.get("maxPayloadBytes") then
return false, RDB_Constants.ERROR_CODES.PAYLOAD_TOO_LARGE, "Request payload exceeds size limit"
end
return true
end
-- Requires: v (number, ==PROTOCOL_VERSION), id (non-empty string), op (non-empty string),
-- args (table or nil).
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
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
return false, RDB_Constants.ERROR_CODES.SCHEMA_ERROR, "Missing or invalid id"
end
if type(obj.op) ~= "string" or #obj.op == 0 then
return false, RDB_Constants.ERROR_CODES.SCHEMA_ERROR, "Missing or invalid op"
end
if obj.args ~= nil and type(obj.args) ~= "table" then
return false, RDB_Constants.ERROR_CODES.SCHEMA_ERROR, "args must be an object when present"
end
return true
end
local requestTimestamps = {}
function RDB_Security.checkRateLimit()
local now = getTimestampMs()
local windowMs = RDB_Config.get("rateLimitWindowSeconds") * 1000
local maxRequests = RDB_Config.get("rateLimitMaxRequests")
local kept = {}
for _, ts in ipairs(requestTimestamps) do
if now - ts < windowMs then
kept[#kept + 1] = ts
end
end
requestTimestamps = kept
if #requestTimestamps >= maxRequests then
return false, RDB_Constants.ERROR_CODES.RATE_LIMITED, "Too many requests, slow down"
end
requestTimestamps[#requestTimestamps + 1] = now
return true
end

View File

@@ -0,0 +1,49 @@
-- World telemetry, sourced from zombie.GameTime (getGameTime()).
require("RconDataBridge.RDB_Constants")
require("RconDataBridge.RDB_Log")
require("RconDataBridge.RDB_Json")
require("RconDataBridge.RDB_OptionsRegistry")
RDB_WorldStats = {}
local cached = {}
function RDB_WorldStats.collect()
return cached
end
local function refresh()
local gt = getGameTime()
cached = {
worldAgeHours = gt:getWorldAgeHours(),
worldAgeDays = gt:getWorldAgeDaysSinceBegin(),
nightsSurvived = gt:getNightsSurvived(),
date = {
year = gt:getYear(),
month = gt:getMonth(),
day = gt:getDay(),
hour = gt:getHour(),
minute = gt:getMinutes(),
},
isNight = gt:isNight(),
isRaining = gt:isRainingToday(),
timeMultiplier = gt:getMultiplier(),
generatedAt = getTimestampMs(),
}
RDB_OptionsRegistry.set(RDB_Constants.OPT.WORLD_STATS, RDB_Json.encode(cached))
end
local initialized = false
function RDB_WorldStats.init()
if initialized then
return
end
initialized = true
refresh()
Events.EveryOneMinute.Add(refresh)
end

View File

@@ -0,0 +1,106 @@
---@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.
--
-- Applied uniformly to every RconDataBridge_* option in both directions
-- (see RDB_OptionsRegistry.register/set), not just the request channel that
-- strictly needs it: a client only ever has to deal with one rule ("every
-- value is base64"), never a request-only exception to remember.
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

View File

@@ -0,0 +1,44 @@
-- 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
-- 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",
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",
}

View File

@@ -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

View File

@@ -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