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
+438
View File
@@ -0,0 +1,438 @@
-- ---------------------------------------------------------------------------
-- rp_session - everything between "player opened the server" and "player is
-- standing in the world": ban gate, accounts, characters, spawn.
--
-- The client drives the UI, but every decision is made here. The client may
-- only ever ask; it can never assert that it is logged in, that a character
-- belongs to it, or where it spawned.
-- ---------------------------------------------------------------------------
local sessions = {} -- [src] = { license, ip, accountId, username, role, stage }
-- This build of FXServer ships no `hardcap` resource (it only advertises the
-- name in /info.json), and the bundled `chat` and `monitor` resources are not
-- started, so capacity has to be enforced here - see the deferral below.
-- Kept as a guard in case a future artefact does ship one.
CreateThread(function()
if GetResourceState('hardcap') == 'started' then
StopResource('hardcap')
print('^5[rp_session]^7 stopped bundled hardcap; capacity is enforced by this resource')
end
end)
local STAGE_AUTH = 'auth'
local STAGE_CHARS = 'characters'
local STAGE_SPAWN = 'spawn'
local STAGE_LIVE = 'live'
-- ---------------------------------------------------------------------------
-- helpers
-- ---------------------------------------------------------------------------
local function licenseOf(src)
for i = 0, GetNumPlayerIdentifiers(src) - 1 do
local id = GetPlayerIdentifier(src, i)
if id and id:sub(1, 8) == 'license:' then return id:sub(9) end
end
return nil
end
local function hashPassword(pw)
local p = promise.new()
exports.rp_core:hashPassword(pw, function(hash, err) p:resolve({ hash = hash, err = err }) end)
local r = Citizen.Await(p)
return r.hash, r.err
end
local function verifyPassword(pw, stored)
local p = promise.new()
exports.rp_core:verifyPassword(pw, stored, function(ok, err) p:resolve({ ok = ok, err = err }) end)
local r = Citizen.Await(p)
if r.err then print(('^1[rp_session]^7 password verify error: %s'):format(r.err)) end
return r.ok == true
end
local function recordAttempt(identity, ip, success)
DB.InsertAsync('INSERT INTO auth_attempts (identity, ip, success) VALUES (?, ?, ?)',
{ identity, ip or DB.NULL, success and 1 or 0 })
end
--- True when this identity has burned through its allowance recently.
local function throttled(identity)
local n = DB.Scalar(
'SELECT COUNT(*) FROM auth_attempts WHERE identity = ? AND success = 0 AND at > (NOW() - INTERVAL ? SECOND)',
{ identity, Config.Session.attemptWindowS })
return (tonumber(n) or 0) >= Config.Session.maxAttempts
end
local function characterRows(accountId)
return DB.Query([[
SELECT id, slot, first_name, last_name, dob, gender, backstory, appearance,
cash, bank, job, job_grade, playtime, last_played_at
FROM characters
WHERE account_id = ? AND deleted = 0
ORDER BY slot ASC, id ASC
]], { accountId }) or {}
end
--- Trim a DB row down to what the character-select screen is allowed to see.
local function characterCard(row)
return {
id = row.id,
firstName = row.first_name,
lastName = row.last_name,
dob = tostring(row.dob):sub(1, 10),
gender = row.gender,
backstory = row.backstory,
cash = math.floor(tonumber(row.cash) or 0),
bank = math.floor(tonumber(row.bank) or 0),
job = row.job,
playtime = math.floor(tonumber(row.playtime) or 0),
lastPlayed= row.last_played_at and tostring(row.last_played_at) or nil,
appearance= row.appearance and json.decode(row.appearance) or nil,
}
end
local function sessionOf(src, requiredStage)
local s = sessions[src]
if not s then return nil, 'no session' end
if requiredStage and s.stage ~= requiredStage then return nil, 'wrong stage' end
return s
end
-- ---------------------------------------------------------------------------
-- connection gate
-- ---------------------------------------------------------------------------
AddEventHandler('playerConnecting', function(_, _, deferrals)
local src = source
deferrals.defer()
Wait(0)
local license = licenseOf(src)
if not license then
return deferrals.done('Could not read your FiveM licence identifier. Restart FiveM and try again.')
end
-- Capacity. This replaces the stock `hardcap` resource, which is stopped in
-- server.cfg so that everything running here is our own code.
local maxClients = tonumber(GetConvar('sv_maxclients', '48')) or 48
local online = #GetPlayers() -- the joining player is not counted yet
if online >= maxClients then
return deferrals.done(
('The server is full (%d of %d). Try again in a few minutes.'):format(online, maxClients))
end
deferrals.update('Checking your record...')
if not DB.WaitReady(15000) then
return deferrals.done('The server database is not available right now. Try again in a minute.')
end
local ban = DB.Single([[
SELECT id, ban_reason, ban_until
FROM accounts
WHERE license = ? AND banned = 1
AND (ban_until IS NULL OR ban_until > NOW())
LIMIT 1
]], { license })
if ban then
local until_ = ban.ban_until and (' until ' .. tostring(ban.ban_until) .. ' UTC') or ' permanently'
return deferrals.done(('You are banned%s.\n\nReason: %s')
:format(until_, ban.ban_reason or 'no reason recorded'))
end
-- clear bans that have run out so the account is usable again
DB.UpdateAsync([[
UPDATE accounts SET banned = 0, ban_reason = NULL, ban_until = NULL
WHERE license = ? AND banned = 1 AND ban_until IS NOT NULL AND ban_until <= NOW()
]], { license })
sessions[src] = { license = license, ip = GetPlayerEndpoint(src), stage = STAGE_AUTH }
deferrals.done()
end)
AddEventHandler('playerDropped', function()
sessions[source] = nil
end)
-- ---------------------------------------------------------------------------
-- callbacks: authentication
-- ---------------------------------------------------------------------------
Core.RegisterCallback('session:state', function(src)
local s = sessions[src]
if not s then return nil, 'no session' end
return { stage = s.stage, serverName = Config.ServerName, maxChars = Config.MaxChars }
end)
Core.RegisterCallback('auth:register', function(src, data)
local s, e = sessionOf(src, STAGE_AUTH)
if not s then return nil, e end
if type(data) ~= 'table' then return nil, 'malformed request' end
local username = Util.trim(data.username or '')
local password = data.password or ''
local ok, why = Rules.checkUsername(username)
if not ok then return nil, why end
ok, why = Rules.checkPassword(password)
if not ok then return nil, why end
if throttled(s.license) then
return nil, 'Too many attempts. Wait a few minutes and try again.'
end
local taken = DB.Scalar('SELECT id FROM accounts WHERE username = ? LIMIT 1', { username })
if taken then
recordAttempt(s.license, s.ip, false)
return nil, 'That username is already taken'
end
local hash, herr = hashPassword(password)
if not hash then
print(('^1[rp_session]^7 hashing failed: %s'):format(tostring(herr)))
return nil, 'Could not create the account, try again'
end
local id, ierr = DB.Insert(
'INSERT INTO accounts (username, password_hash, license, last_login_at, last_ip) VALUES (?, ?, ?, NOW(), ?)',
{ username, hash, s.license, s.ip or DB.NULL })
if not id then
-- unique key race: someone registered the same name a moment ago
print(('^1[rp_session]^7 register insert failed: %s'):format(tostring(ierr)))
return nil, 'That username is already taken'
end
recordAttempt(s.license, s.ip, true)
s.accountId = id
s.username = username
s.role = 'player'
s.stage = STAGE_CHARS
exports.rp_core:attachAccount(src, { id = id, username = username, role = 'player', license = s.license })
print(('^2[rp_session]^7 new account %q (id %d) from %s'):format(username, id, GetPlayerName(src)))
return { username = username, characters = {}, maxChars = Config.MaxChars }
end)
Core.RegisterCallback('auth:login', function(src, data)
local s, e = sessionOf(src, STAGE_AUTH)
if not s then return nil, e end
if type(data) ~= 'table' then return nil, 'malformed request' end
local username = Util.trim(data.username or '')
local password = data.password or ''
if username == '' or password == '' then return nil, 'Enter your username and password' end
if throttled(s.license) then
return nil, 'Too many failed attempts. Wait a few minutes and try again.'
end
local acc = DB.Single(
'SELECT id, username, password_hash, role, banned, ban_reason, ban_until FROM accounts WHERE username = ? LIMIT 1',
{ username })
-- Always run a verification so a missing username and a wrong password take
-- the same amount of time and cannot be told apart from outside.
local stored = acc and acc.password_hash
or 'scrypt$16384$8$1$AAAAAAAAAAAAAAAAAAAAAA==$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
local good = verifyPassword(password, stored)
if not acc or not good then
recordAttempt(s.license, s.ip, false)
return nil, 'Incorrect username or password'
end
if acc.banned == 1 then
-- re-checked against NOW() so an expired ban does not keep locking them out
local active = DB.Scalar(
'SELECT 1 FROM accounts WHERE id = ? AND banned = 1 AND (ban_until IS NULL OR ban_until > NOW())',
{ acc.id })
if active == 1 then
return nil, ('This account is banned. Reason: %s'):format(acc.ban_reason or 'no reason recorded')
end
end
recordAttempt(s.license, s.ip, true)
DB.UpdateAsync('UPDATE accounts SET last_login_at = NOW(), last_ip = ?, license = ? WHERE id = ?',
{ s.ip or DB.NULL, s.license, acc.id })
s.accountId = acc.id
s.username = acc.username
s.role = acc.role
s.stage = STAGE_CHARS
exports.rp_core:attachAccount(src, { id = acc.id, username = acc.username, role = acc.role, license = s.license })
local cards = {}
for _, row in ipairs(characterRows(acc.id)) do cards[#cards + 1] = characterCard(row) end
print(('^2[rp_session]^7 %s logged in as %q'):format(GetPlayerName(src), acc.username))
return { username = acc.username, role = acc.role, characters = cards, maxChars = Config.MaxChars }
end)
-- ---------------------------------------------------------------------------
-- callbacks: characters
-- ---------------------------------------------------------------------------
Core.RegisterCallback('char:list', function(src)
local s, e = sessionOf(src, STAGE_CHARS)
if not s then return nil, e end
local cards = {}
for _, row in ipairs(characterRows(s.accountId)) do cards[#cards + 1] = characterCard(row) end
return { characters = cards, maxChars = Config.MaxChars }
end)
Core.RegisterCallback('char:create', function(src, data)
local s, e = sessionOf(src, STAGE_CHARS)
if not s then return nil, e end
if type(data) ~= 'table' then return nil, 'malformed request' end
local first = Util.properName(data.firstName or '')
local last = Util.properName(data.lastName or '')
local ok, why = Rules.checkName(first)
if not ok then return nil, 'First name: ' .. why end
ok, why = Rules.checkName(last)
if not ok then return nil, 'Last name: ' .. why end
ok, why = Rules.checkDob(data.dob or '')
if not ok then return nil, 'Date of birth: ' .. why end
local gender = (data.gender == 'f') and 'f' or 'm'
local backstory = Util.trim(data.backstory or '')
if #backstory > 2000 then return nil, 'Backstory is too long (2000 characters max)' end
local existing = tonumber(DB.Scalar(
'SELECT COUNT(*) FROM characters WHERE account_id = ? AND deleted = 0', { s.accountId })) or 0
if existing >= Config.MaxChars then
return nil, ('You already have %d characters'):format(Config.MaxChars)
end
local clash = DB.Scalar(
'SELECT id FROM characters WHERE first_name = ? AND last_name = ? LIMIT 1', { first, last })
if clash then return nil, 'Someone on this server already has that name' end
local appearance = Appearance.Sanitise(data.appearance)
local id, ierr = DB.Insert([[
INSERT INTO characters (account_id, slot, first_name, last_name, dob, gender, backstory,
appearance, cash, bank)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
]], {
s.accountId, existing, first, last, data.dob, gender,
backstory ~= '' and backstory or DB.NULL,
json.encode(appearance), Config.Money.startingCash, Config.Money.startingBank,
})
if not id then
print(('^1[rp_session]^7 character insert failed: %s'):format(tostring(ierr)))
return nil, 'Could not create that character'
end
print(('^2[rp_session]^7 %s created character %s %s (id %d)'):format(s.username, first, last, id))
local row = DB.Single([[
SELECT id, slot, first_name, last_name, dob, gender, backstory, appearance,
cash, bank, job, job_grade, playtime, last_played_at
FROM characters WHERE id = ?
]], { id })
return { character = characterCard(row) }
end)
Core.RegisterCallback('char:delete', function(src, data)
local s, e = sessionOf(src, STAGE_CHARS)
if not s then return nil, e end
local id = tonumber(type(data) == 'table' and data.id or nil)
if not id then return nil, 'malformed request' end
-- ownership is checked in the WHERE clause, so a forged id changes nothing
local n = DB.Update('UPDATE characters SET deleted = 1 WHERE id = ? AND account_id = ? AND deleted = 0',
{ id, s.accountId })
if (tonumber(n) or 0) == 0 then return nil, 'That character is not yours' end
print(('^3[rp_session]^7 %s deleted character %d'):format(s.username, id))
return { deleted = id }
end)
Core.RegisterCallback('char:select', function(src, data)
local s, e = sessionOf(src, STAGE_CHARS)
if not s then return nil, e end
local id = tonumber(type(data) == 'table' and data.id or nil)
if not id then return nil, 'malformed request' end
local row = DB.Single([[
SELECT * FROM characters WHERE id = ? AND account_id = ? AND deleted = 0
]], { id, s.accountId })
if not row then return nil, 'That character is not yours' end
exports.rp_core:attachCharacter(src, row)
s.stage = STAGE_SPAWN
s.charId = id
local last = row.position and json.decode(row.position) or nil
local spawns = {}
for _, sp in ipairs(Config.SpawnPoints) do
spawns[#spawns + 1] = {
id = sp.id, label = sp.label, area = sp.area, blurb = sp.blurb, map = sp.map,
coords = { x = sp.coords.x, y = sp.coords.y, z = sp.coords.z, h = sp.coords.w },
cam = { pos = { x = sp.cam.pos.x, y = sp.cam.pos.y, z = sp.cam.pos.z },
look = { x = sp.cam.look.x, y = sp.cam.look.y, z = sp.cam.look.z } },
}
end
return {
character = characterCard(row),
appearance = row.appearance and json.decode(row.appearance) or nil,
spawns = spawns,
lastPosition = last,
}
end)
-- ---------------------------------------------------------------------------
-- callbacks: spawn
-- ---------------------------------------------------------------------------
Core.RegisterCallback('spawn:confirm', function(src, data)
local s, e = sessionOf(src, STAGE_SPAWN)
if not s then return nil, e end
local choice = type(data) == 'table' and data.id or nil
local coords
if choice == 'last' then
local p = exports.rp_core:getCharacter(src)
local last = p and p.position
if last and last.x then
coords = { x = last.x, y = last.y, z = last.z, h = last.h or 0.0 }
end
end
if not coords then
for _, sp in ipairs(Config.SpawnPoints) do
if sp.id == choice then
coords = { x = sp.coords.x, y = sp.coords.y, z = sp.coords.z, h = sp.coords.w }
break
end
end
end
if not coords then
local sp = Config.SpawnPoints[1]
coords = { x = sp.coords.x, y = sp.coords.y, z = sp.coords.z, h = sp.coords.w }
end
s.stage = STAGE_LIVE
exports.rp_core:setSpawned(src, true)
local char = exports.rp_core:getCharacter(src)
print(('^2[rp_session]^7 %s spawned as %s %s'):format(s.username, char.firstName, char.lastName))
TriggerClientEvent('rp:session:spawned', src, coords)
return { coords = coords, character = char }
end)