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>
145 lines
5.9 KiB
Lua
145 lines
5.9 KiB
Lua
-- ---------------------------------------------------------------------------
|
|
-- End-to-end check of the account and character data path.
|
|
--
|
|
-- The first run creates a marker account. Every later run finds it already
|
|
-- there and verifies it came back byte-for-byte, which is what "persists
|
|
-- between restarts" actually means. Run `selftest_reset` to clear it.
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
local MARKER = 'zz_selftest'
|
|
local PASSWORD = 'correct horse 7'
|
|
|
|
local passed, failed = 0, 0
|
|
|
|
local function check(name, ok, detail)
|
|
if ok then
|
|
passed = passed + 1
|
|
print(('^2 PASS^7 %s'):format(name))
|
|
else
|
|
failed = failed + 1
|
|
print(('^1 FAIL^7 %s ^1%s^7'):format(name, detail or ''))
|
|
end
|
|
end
|
|
|
|
local function hash(pw)
|
|
local p = promise.new()
|
|
exports.rp_core:hashPassword(pw, function(h, err) p:resolve({ h = h, err = err }) end)
|
|
local r = Citizen.Await(p)
|
|
return r.h, r.err
|
|
end
|
|
|
|
local function verify(pw, stored)
|
|
local p = promise.new()
|
|
exports.rp_core:verifyPassword(pw, stored, function(ok, err) p:resolve({ ok = ok, err = err }) end)
|
|
return Citizen.Await(p).ok
|
|
end
|
|
|
|
CreateThread(function()
|
|
if not DB.WaitReady(30000) then
|
|
print('^1[selftest] database never became ready^7')
|
|
return
|
|
end
|
|
|
|
print('^5[selftest]^7 ---- account + character data path ----')
|
|
|
|
-- 1. password hashing ----------------------------------------------------
|
|
local h, herr = hash(PASSWORD)
|
|
check('scrypt hash produced', type(h) == 'string' and h:sub(1, 7) == 'scrypt$', tostring(herr))
|
|
check('correct password verifies', verify(PASSWORD, h))
|
|
check('wrong password rejected', not verify(PASSWORD .. 'x', h))
|
|
check('empty password rejected', not verify('', h))
|
|
|
|
local h2 = hash(PASSWORD)
|
|
check('same password hashes differently (salted)', h ~= h2)
|
|
check('second hash also verifies', verify(PASSWORD, h2))
|
|
check('corrupt hash rejected safely', not verify(PASSWORD, 'not-a-hash'))
|
|
|
|
-- 2. the account row ------------------------------------------------------
|
|
local existing = DB.Single('SELECT id, password_hash, created_at FROM accounts WHERE username = ?', { MARKER })
|
|
local firstRun = existing == nil
|
|
local accountId
|
|
|
|
if firstRun then
|
|
accountId = DB.Insert(
|
|
'INSERT INTO accounts (username, password_hash, license) VALUES (?, ?, ?)',
|
|
{ MARKER, h, 'selftest-license' })
|
|
check('account created', type(accountId) == 'number' and accountId > 0)
|
|
print('^3[selftest]^7 marker account created - restart the server to prove persistence')
|
|
else
|
|
accountId = existing.id
|
|
check('account survived restart', true)
|
|
check('stored hash still verifies after restart', verify(PASSWORD, existing.password_hash))
|
|
print(('^5[selftest]^7 marker account has existed since %s'):format(tostring(existing.created_at)))
|
|
end
|
|
|
|
-- 3. the character row, including the appearance JSON ---------------------
|
|
local appearance = Appearance.Sanitise({
|
|
model = 'f',
|
|
parents = { father = 12, mother = 33, shapeMix = 0.62, skinMix = 0.25 },
|
|
hair = { style = 14, colour = 9, highlight = 2 },
|
|
features = { ['0'] = 0.4, ['13'] = -0.75 },
|
|
overlays = { eyebrows = { index = 5, opacity = 0.8, colour = 3 } },
|
|
components = { jacket = { drawable = 42, texture = 3 } },
|
|
})
|
|
|
|
local charId = DB.Scalar('SELECT id FROM characters WHERE account_id = ? AND deleted = 0 LIMIT 1', { accountId })
|
|
|
|
if not charId then
|
|
charId = DB.Insert([[
|
|
INSERT INTO characters (account_id, slot, first_name, last_name, dob, gender, backstory, appearance)
|
|
VALUES (?, 0, ?, ?, ?, 'f', ?, ?)
|
|
]], { accountId, 'Zztest', 'Persistence', '1991-04-18',
|
|
'Created by the self test.', json.encode(appearance) })
|
|
check('character created', type(charId) == 'number' and charId > 0)
|
|
else
|
|
check('character survived restart', true)
|
|
end
|
|
|
|
local row = DB.Single('SELECT * FROM characters WHERE id = ?', { charId })
|
|
check('character reads back', row ~= nil)
|
|
|
|
if row then
|
|
local back = json.decode(row.appearance)
|
|
check('appearance JSON round trip', json.encode(Appearance.Sanitise(back)) == json.encode(appearance),
|
|
'appearance differs after a round trip')
|
|
check('float precision kept', math.abs(back.parents.shapeMix - 0.62) < 0.0001,
|
|
tostring(back.parents.shapeMix))
|
|
check('negative feature kept', math.abs(back.features['13'] + 0.75) < 0.0001,
|
|
tostring(back.features['13']))
|
|
check('starting balances applied', tonumber(row.cash) == Config.Money.startingCash
|
|
and tonumber(row.bank) == Config.Money.startingBank,
|
|
('cash=%s bank=%s'):format(tostring(row.cash), tostring(row.bank)))
|
|
end
|
|
|
|
-- 4. the constraints the session code relies on ---------------------------
|
|
local dupe, dupeErr = DB.Insert(
|
|
'INSERT INTO accounts (username, password_hash) VALUES (?, ?)', { MARKER, h })
|
|
check('duplicate username refused by the database', dupe == nil, tostring(dupe))
|
|
|
|
local dupeName = DB.Insert([[
|
|
INSERT INTO characters (account_id, slot, first_name, last_name, dob, gender)
|
|
VALUES (?, 1, 'Zztest', 'Persistence', '1991-04-18', 'f')
|
|
]], { accountId })
|
|
check('duplicate character name refused by the database', dupeName == nil)
|
|
|
|
local orphan = DB.Insert([[
|
|
INSERT INTO characters (account_id, slot, first_name, last_name, dob, gender)
|
|
VALUES (999999, 0, 'Zzorphan', 'Nobody', '1991-04-18', 'm')
|
|
]], {})
|
|
check('character cannot reference a missing account', orphan == nil)
|
|
|
|
print(('^5[selftest]^7 ==== %d passed, %d failed ===='):format(passed, failed))
|
|
if failed == 0 then
|
|
print(firstRun and '^2[selftest] data path OK (first run)^7' or '^2[selftest] data path OK and persistent^7')
|
|
else
|
|
print('^1[selftest] PROBLEMS FOUND^7')
|
|
end
|
|
end)
|
|
|
|
RegisterCommand('selftest_reset', function()
|
|
CreateThread(function()
|
|
DB.Update('DELETE FROM accounts WHERE username = ?', { MARKER }) -- characters cascade
|
|
print('^3[selftest]^7 marker account removed')
|
|
end)
|
|
end, true)
|