The server itself: repo now mirrors the machine it runs on, plus install.sh

The repository carried two different servers side by side - the early
`justrp` prototype with its Python auth API, and a snapshot of the real
one under `server-code/`. Only the second one is a server anyone should
start from, so the prototype is gone and the real one moved to the root.

Taken from the live box, so the UI is the finished version (the tokens,
the county-records sheets and the variable fonts landed after the last
snapshot was pushed):

  resources/[rp]/   rp_db, rp_core, rp_session, rp_loading, rp_ui,
                    rp_selftest, rp_dbtest
  bin/              supervise.sh (keep-alive + console FIFO), rcon, init script
  etc/schema.sql    accounts, characters, transactions, inventory, vehicles
  assets/fonts/     the bundled subsets
  docs/SERVER.md    how it is put together, resource by resource

install.sh turns a clean Ubuntu/Debian box into this server in one
command: recommended FXServer build, MariaDB with the schema, resources,
server.cfg + a generated database password, boot entry (systemd or
init.d), then it waits for the Cfx registration. --name/--port/--db-name
let a second server live on the same machine. Tested end to end on a
spare install root: build 25770 fetched, schema applied, boot entry
written, FXServer started and stopped exactly where a wrong licence key
should stop it.

No secrets travel with it: the licence key and the database password live
in data/secrets.cfg on the machine, and .gitignore now names it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Claude Opus 5
2026-08-12 23:23:33 +00:00
co-authored by Claude Opus 5
parent 80c1d75d2f
commit 71856b15e9
66 changed files with 2030 additions and 3814 deletions
@@ -0,0 +1,46 @@
-- ---------------------------------------------------------------------------
-- Client half of the request/response bridge.
-- Include with: client_scripts { '@rp_core/lib/callbacks_client.lua', ... }
--
-- Usage (inside a thread):
-- local result, err = Core.Callback('rp_session', 'auth:login', { ... })
--
-- Reply events are namespaced by the *calling* resource, so two resources
-- can never resolve each other's tokens.
-- ---------------------------------------------------------------------------
Core = Core or {}
local RES = GetCurrentResourceName()
local pending = {}
local seq = 0
RegisterNetEvent('rp:cb:res:' .. RES, function(token, result, err)
local p = pending[token]
if not p then return end -- already timed out
pending[token] = nil
p:resolve({ result = result, err = err })
end)
--- Blocking request to a server callback. Returns result, err.
--- Always returns; a lost reply surfaces as an error rather than a hang.
function Core.Callback(targetResource, name, payload, timeoutMs)
seq = seq + 1
local token = seq
local p = promise.new()
pending[token] = p
TriggerServerEvent('rp:cb:' .. targetResource, RES, name, token, payload)
CreateThread(function()
Wait(timeoutMs or 15000)
local waiting = pending[token]
if waiting then
pending[token] = nil
waiting:resolve({ result = nil, err = 'no response from ' .. targetResource .. '/' .. name })
end
end)
local r = Citizen.Await(p)
return r.result, r.err
end
@@ -0,0 +1,78 @@
-- ---------------------------------------------------------------------------
-- Server half of the request/response bridge.
-- Include with: server_scripts { '@rp_core/lib/callbacks_server.lua', ... }
--
-- Core.RegisterCallback('auth:login', function(src, payload)
-- return result, err -- err ~= nil is reported to the client
-- end)
--
-- Handlers run in their own thread so they may block on the database.
-- Every handler is rate limited per player; a client that floods is ignored
-- rather than allowed to queue unbounded database work.
-- ---------------------------------------------------------------------------
Core = Core or {}
local RES = GetCurrentResourceName()
local handlers = {}
local buckets = {} -- [src] = { tokens = n, last = ms }
local BUCKET_MAX = 15 -- burst
local BUCKET_RATE = 5 -- refilled per second
local function allow(src)
local now = GetGameTimer()
local b = buckets[src]
if not b then
b = { tokens = BUCKET_MAX, last = now }
buckets[src] = b
end
local elapsed = (now - b.last) / 1000
b.last = now
b.tokens = math.min(BUCKET_MAX, b.tokens + elapsed * BUCKET_RATE)
if b.tokens < 1 then return false end
b.tokens = b.tokens - 1
return true
end
AddEventHandler('playerDropped', function()
buckets[source] = nil
end)
function Core.RegisterCallback(name, fn)
if handlers[name] then
print(('^3[rp_core]^7 callback %q registered twice in %s'):format(name, RES))
end
handlers[name] = fn
end
RegisterNetEvent('rp:cb:' .. RES, function(fromResource, name, token, payload)
local src = source
-- A client controls fromResource/token, so they are only ever echoed back to
-- that same client. They are never used to look anything up on the server.
if type(fromResource) ~= 'string' or type(name) ~= 'string' then return end
local reply = function(result, err)
TriggerClientEvent('rp:cb:res:' .. fromResource, src, token, result, err)
end
if not allow(src) then
return reply(nil, 'slow down')
end
local fn = handlers[name]
if not fn then
print(('^3[rp_core]^7 %s: unknown callback %q from %d'):format(RES, name, src))
return reply(nil, 'unknown request')
end
CreateThread(function()
local ok, result, err = pcall(fn, src, payload)
if not ok then
print(('^1[rp_core]^7 callback %q errored: %s'):format(name, tostring(result)))
return reply(nil, 'internal error')
end
reply(result, err)
end)
end)