Compare commits
17 Commits
c2859cb5f7
...
v1.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 3bd4815ee2 | |||
| 2aaa327853 | |||
| 806bd8ae6a | |||
| 60c2c718eb | |||
| 52abbc5643 | |||
| dd801de28d | |||
| 439105ec60 | |||
| b5a9ab3b5d | |||
| 854c19dca7 | |||
| 3a02db5717 | |||
| 9de6799a5e | |||
| b0b4a815c2 | |||
| 3bebae73ed | |||
| d0d466881e | |||
| 19376c3efa | |||
| 96b8e6263e | |||
| 26001a22fc |
18
.gitignore
vendored
18
.gitignore
vendored
@@ -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
|
||||
|
||||
|
||||
|
||||
7
Contents/mods/RconDataBridge/42/mod.info
Normal file
7
Contents/mods/RconDataBridge/42/mod.info
Normal 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
|
||||
BIN
Contents/mods/RconDataBridge/42/poster.png
Normal file
BIN
Contents/mods/RconDataBridge/42/poster.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 141 KiB |
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,88 @@
|
||||
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")
|
||||
require("RconDataBridge.Ops.RDB_OpBridgeStatus")
|
||||
require("RconDataBridge.Ops.RDB_OpAuditLogGet")
|
||||
|
||||
RDB_Bootstrap = {}
|
||||
|
||||
local bootTimestamp
|
||||
|
||||
function RDB_Bootstrap.getBootTimestamp()
|
||||
return bootTimestamp
|
||||
end
|
||||
|
||||
local function runSelfTests()
|
||||
if not RDB_Config.get("DebugSelfTest") 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
|
||||
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()
|
||||
RDB_OpWorldGetStats.register()
|
||||
RDB_OpPlayerGet.register()
|
||||
RDB_OpPlayerList.register()
|
||||
RDB_OpServerSave.register()
|
||||
RDB_OpBridgeStatus.register()
|
||||
RDB_OpAuditLogGet.register()
|
||||
end
|
||||
|
||||
local function publishProtocolVersion()
|
||||
local json = RDB_Json.encode({
|
||||
version = RDB_Config.get("ProtocolVersion"),
|
||||
ops = RDB_OpRegistry.listNames(),
|
||||
})
|
||||
|
||||
RDB_OptionsRegistry.set(RDB_Constants.OPT.PROTOCOL_VERSION, json)
|
||||
end
|
||||
|
||||
local function onServerStarted()
|
||||
if not bootTimestamp then
|
||||
bootTimestamp = getTimestampMs()
|
||||
end
|
||||
runSelfTests()
|
||||
registerBaseOptions()
|
||||
registerOps()
|
||||
publishProtocolVersion()
|
||||
RDB_RequestPipeline.init()
|
||||
RDB_WorldStats.init()
|
||||
RDB_PlayerSnapshot.init()
|
||||
RDB_Log.info("bootstrap complete")
|
||||
end
|
||||
|
||||
Events.OnServerStarted.Add(onServerStarted)
|
||||
@@ -0,0 +1,45 @@
|
||||
---@diagnostic disable: need-check-nil
|
||||
|
||||
-- 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 = {}
|
||||
|
||||
-- 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)
|
||||
local v = SandboxVars.RconDataBridge[key]
|
||||
if v ~= nil then
|
||||
return v
|
||||
end
|
||||
|
||||
return RDB_Constants[toConstantKey(key)]
|
||||
end
|
||||
|
||||
function RDB_Config.getAll()
|
||||
return {
|
||||
maxPayloadBytes = RDB_Config.get("MaxPayloadBytes"),
|
||||
rateLimitMaxRequests = RDB_Config.get("RateLimitMaxRequests"),
|
||||
rateLimitWindowSeconds = RDB_Config.get("RateLimitWindowSeconds"),
|
||||
}
|
||||
end
|
||||
@@ -0,0 +1,86 @@
|
||||
-- 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_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 = {}
|
||||
|
||||
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)
|
||||
local ttlMs = RDB_Config.get("IdempotencyTtlSeconds") * 1000
|
||||
return getTimestampMs() - (entry.ts or 0) >= ttlMs
|
||||
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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,156 @@
|
||||
-- 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")
|
||||
require("RconDataBridge.RDB_Config")
|
||||
|
||||
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_Config.get("PlayerStatsPrefix") .. id:gsub("[^%w_]", "_")
|
||||
RDB_OptionsRegistry.register(optionName, "{}", RDB_Config.get("OptionMaxLength"))
|
||||
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_Config.get("PollEveryTicks") 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
|
||||
@@ -0,0 +1,189 @@
|
||||
-- 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_Config")
|
||||
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_Config.get("ProtocolVersion"),
|
||||
id = id,
|
||||
ok = true,
|
||||
data = data or {}
|
||||
}
|
||||
end
|
||||
|
||||
local function buildErrorResponse(id, code, message)
|
||||
return {
|
||||
v = RDB_Config.get("ProtocolVersion"),
|
||||
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
|
||||
|
||||
-- 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
|
||||
-- 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)
|
||||
|
||||
local cachedErrCode = nil
|
||||
if not cached.ok then
|
||||
cachedErrCode = cached.error and cached.error.code
|
||||
end
|
||||
audit(id, op, cached.ok, cachedErrCode)
|
||||
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 = safeDispatch(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)
|
||||
|
||||
local errCode = nil
|
||||
if not dispatchOk then
|
||||
errCode = dataOrCode
|
||||
end
|
||||
audit(id, op, dispatchOk, errCode)
|
||||
end
|
||||
|
||||
local function onTick()
|
||||
tickCounter = tickCounter + 1
|
||||
if tickCounter < RDB_Config.get("PollEveryTicks") 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,
|
||||
-- and Events.OnTick.Add does not deduplicate identical listeners,
|
||||
-- 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()
|
||||
if initialized then
|
||||
return
|
||||
end
|
||||
|
||||
initialized = true
|
||||
lastSeenRaw = RDB_OptionsRegistry.get(RDB_Constants.OPT.REQUEST)
|
||||
|
||||
Events.OnTick.Add(onTick)
|
||||
Events.OnTickEvenPaused.Add(onTick)
|
||||
end
|
||||
@@ -0,0 +1,79 @@
|
||||
-- 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_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
|
||||
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 = {}
|
||||
|
||||
-- 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 windowMs = RDB_Config.get("RateLimitWindowSeconds") * 1000
|
||||
|
||||
local kept = {}
|
||||
for _, ts in ipairs(requestTimestamps) do
|
||||
if now - ts < windowMs then
|
||||
kept[#kept + 1] = ts
|
||||
end
|
||||
end
|
||||
requestTimestamps = kept
|
||||
|
||||
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"
|
||||
end
|
||||
|
||||
requestTimestamps[#requestTimestamps + 1] = getTimestampMs()
|
||||
return true
|
||||
end
|
||||
|
||||
function RDB_Security.getRateLimitStatus()
|
||||
return {
|
||||
currentWindowRequests = pruneAndCount(),
|
||||
maxRequests = RDB_Config.get("RateLimitMaxRequests"),
|
||||
windowSeconds = RDB_Config.get("RateLimitWindowSeconds"),
|
||||
}
|
||||
end
|
||||
@@ -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 = RainManager.isRaining(), -- GameTime:isRainingToday() is a dead stub in B42.20
|
||||
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
|
||||
@@ -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
|
||||
@@ -0,0 +1,32 @@
|
||||
-- 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.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.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
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user