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:
co-authored by
Claude Opus 5
parent
80c1d75d2f
commit
71856b15e9
@@ -0,0 +1,19 @@
|
||||
fx_version 'cerulean'
|
||||
game 'gta5'
|
||||
|
||||
name 'rp_session'
|
||||
description 'Connection gate, accounts, characters and spawn'
|
||||
author 'Los Santos RP'
|
||||
version '1.0.0'
|
||||
|
||||
shared_scripts {
|
||||
'@rp_core/shared/config.lua',
|
||||
'@rp_core/shared/util.lua',
|
||||
'@rp_core/shared/appearance.lua',
|
||||
}
|
||||
|
||||
server_scripts {
|
||||
'@rp_db/lib/db.lua',
|
||||
'@rp_core/lib/callbacks_server.lua',
|
||||
'server/main.lua',
|
||||
}
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user