Skip to content

Commit

Permalink
Merge branch 'develop'
Browse files Browse the repository at this point in the history
  • Loading branch information
tabarra committed Mar 29, 2024
2 parents 7d718ba + f414022 commit ee496b5
Show file tree
Hide file tree
Showing 114 changed files with 2,436 additions and 818 deletions.
3 changes: 2 additions & 1 deletion .deploy.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ export default {
// '+set', 'txAdminPort', '40125',
// '--trace-warnings',
// '--inspect',
// '--max-old-space-size=4096',
// '--trace-gc',
// '--max-old-space-size=4096', //doesn't work

//FIXME: broken
// '+set', 'txDebugPlayerlistGenerator', 'true',
Expand Down
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
MIT License

Copyright (c) 2019-2023 André Tabarra <[email protected]>
Copyright (c) 2019-2024 André Tabarra <[email protected]>

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
- Auto Restart FXServer on crash or hang
- Server’s CPU/RAM consumption
- Live Console (with log file, command history and search)
- Server tick time performance chart with player count ([example](https://i.imgur.com/VG8hpzr.gif))
- Server threads performance chart with player count
- Server Activity Log (connections/disconnections, kills, chat, explosions and [custom commands](docs/custom_serverlog.md))
- Player Manager:
- [Warning system](https://www.youtube.com/watch?v=DeE0-5vtZ4E)
Expand Down
60 changes: 23 additions & 37 deletions core/components/AdminVault/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export default class AdminVault {
'control.server': 'Start/Stop Server + Scheduler',
'commands.resources': 'Start/Stop Resources',
'server.cfg.editor': 'Read/Write server.cfg',
'txadmin.log.view': 'View txAdmin Log',
'txadmin.log.view': 'View System Logs', //FIXME: rename to system.log.view

'menu.vehicle': 'Spawn / Fix Vehicles',
'menu.clear_area': 'Reset world area',
Expand Down Expand Up @@ -216,6 +216,20 @@ export default class AdminVault {
}


/**
* Returns an array with all identifiers of the admins (fivem/discord)
*/
getAdminsIdentifiers() {
if (this.admins === false) return [];
const ids = [];
for (const admin of this.admins) {
admin.providers.citizenfx && ids.push(admin.providers.citizenfx.identifier);
admin.providers.discord && ids.push(admin.providers.discord.identifier);
}
return ids;
}


/**
* Returns all data from an admin by their name, or false
* @param {string} uname
Expand Down Expand Up @@ -398,8 +412,13 @@ export default class AdminVault {
}
if (typeof permissions !== 'undefined') this.admins[adminIndex].permissions = permissions;

//Prevent race condition, will allow the session to be updated before refreshing socket.io
//sessions which will cause reauth and closing of the temp password modal on first access
setTimeout(() => {
this.refreshOnlineAdmins().catch((e) => { });
}, 250);

//Saving admin file
this.refreshOnlineAdmins().catch((e) => { });
try {
await this.writeAdminsFile();
return (password !== null) ? this.admins[adminIndex].password_hash : true;
Expand All @@ -409,40 +428,6 @@ export default class AdminVault {
}


/**
* Refreshes admin's social login data
* TODO: this should be stored on PersistentCache instead of admins.json
* otherwise it refreshes the admins connected
* @param {string} name
* @param {string} provider
* @param {string} identifier
* @param {object} providerData
*/
async refreshAdminSocialData(name, provider, identifier, providerData) {
if (this.admins == false) throw new Error('Admins not set');

//Find admin index
const username = name.toLowerCase();
const adminIndex = this.admins.findIndex((user) => {
return (username === user.name.toLowerCase());
});
if (adminIndex == -1) throw new Error('Admin not found');

//Refresh admin data
if (!this.admins[adminIndex].providers[provider]) throw new Error('Provider not available for this admin');
this.admins[adminIndex].providers[provider].identifier = identifier;
this.admins[adminIndex].providers[provider].data = providerData;

//Saving admin file
this.refreshOnlineAdmins().catch((e) => { });
try {
return await this.writeAdminsFile();
} catch (error) {
throw new Error(`Failed to save admins.json with error: ${error.message}`);
}
}


/**
* Delete admin and save to the admins file
* @param {string} name
Expand Down Expand Up @@ -549,7 +534,8 @@ export default class AdminVault {
}

this.admins = jsonData;
this.refreshOnlineAdmins().catch((e) => { });
//NOTE: since this runs only at the start, nobody is online yet
// this.refreshOnlineAdmins().catch((e) => { });
if (migrated) {
try {
await this.writeAdminsFile();
Expand Down
4 changes: 2 additions & 2 deletions core/components/DiscordBot/commands/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,8 +292,8 @@ export default async (interaction: ChatInputCommandInteraction, txAdmin: TxAdmin
} catch (error) {
let msg: string;
if((error as any).code === 50013){
msg = `This bot does not have permission to send messages in this channel.
Please edit the channel and give this bot the "Send Messages" permission.`
msg = `This bot does not have permission to send embed messages in this channel.
Please change the channel permissions and give this bot the \`Embed Links\` and \`Send Messages\` permissions.`
}else{
msg = (error as Error).message;
}
Expand Down
4 changes: 2 additions & 2 deletions core/components/DiscordBot/defaultJsons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,10 @@ export const defaultEmbedJson = JSON.stringify({
}
],
"image": {
"url": "https://i.imgur.com/ZZRp4pj.png"
"url": "https://forum-cfx-re.akamaized.net/original/5X/e/e/c/b/eecb4664ee03d39e34fcd82a075a18c24add91ed.png"
},
"thumbnail": {
"url": "https://i.imgur.com/9i9lvOp.png"
"url": "https://forum-cfx-re.akamaized.net/original/5X/9/b/d/7/9bd744dc2b21804e18c3bb331e8902c930624e44.png"
}
}, null, 2);

Expand Down
29 changes: 29 additions & 0 deletions core/components/PlayerDatabase/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -425,8 +425,37 @@ export default class PlayerDatabase {
}


/**
* Returns players stats for the database (for Players page callouts)
*/
getPlayersStats() {
if (!this.#db.obj || !this.#db.obj.data) throw new Error(`database not ready yet`);

const oneDayAgo = now() - (24 * 60 * 60);
const sevenDaysAgo = now() - (7 * 24 * 60 * 60);
const startingValue = {
total: 0,
playedLast24h: 0,
joinedLast24h: 0,
joinedLast7d: 0,
};
const playerStats = this.#db.obj.chain.get('players')
.reduce((acc, p, ind) => {
acc.total++;
if (p.tsLastConnection > oneDayAgo) acc.playedLast24h++;
if (p.tsJoined > oneDayAgo) acc.joinedLast24h++;
if (p.tsJoined > sevenDaysAgo) acc.joinedLast7d++;
return acc;
}, startingValue)
.value();

return playerStats;
}


/**
* Returns actions/players stats for the database
* FIXME: deprecate, used by the old players page
*/
getDatabaseStats() {
if (!this.#db.obj || !this.#db.obj.data) throw new Error(`database not ready yet`);
Expand Down
10 changes: 8 additions & 2 deletions core/components/PlayerlistManager/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,11 @@ export default class PlayerlistManager {


/**
* Handler for server restart - it will kill all players and reset the licenseCache
* Handler for server restart - it will kill all players
* We MUST do .disconnect() for all players to clear the timers.
* NOTE: it's ok for us to overfill before slicing the licenseCache because it's at most ~4mb
*/
handleServerStop(oldMutex: string) {
this.licenseCache = [];
for (const player of this.#playerlist) {
if (player) {
player.disconnect();
Expand Down Expand Up @@ -117,6 +116,13 @@ export default class PlayerlistManager {
return this.#playerlist.filter(p => p && p.license === searchLicense && p.isConnected) as ServerPlayer[];
}

/**
* Returns a set of all online players' licenses.
*/
getOnlinePlayersLicenses() {
return new Set(this.#playerlist.filter(p => p && p.isConnected).map(p => p!.license));
}


/**
* Handler for all txAdminPlayerlistEvent structured trace events
Expand Down
8 changes: 6 additions & 2 deletions core/components/WebServer/authLogic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,8 +258,12 @@ export const nuiAuthLogic = (
// Searching for admin in AdminVault
const vaultAdmin = txAdmin.adminVault.getAdminByIdentifiers(identifiers);
if (!vaultAdmin) {
//this one is handled differently in resource/menu/client/cl_base.lua
return failResp('admin_not_found');
if(!reqHeader['x-txadmin-identifiers'].includes('license:')) {
return failResp('Unauthorized: you do not have a license identifier, which means the server probably has sv_lan enabled. Please disable sv_lan and restart the server to use the in-game menu.');
} else {
//this one is handled differently in resource/menu/client/cl_base.lua
return failResp('admin_not_found');
}
}
return successResp(txAdmin, vaultAdmin, undefined);
} catch (error) {
Expand Down
28 changes: 23 additions & 5 deletions core/components/WebServer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ import topLevelMw from './middlewares/topLevelMw';
import ctxVarsMw from './middlewares/ctxVarsMw';
import ctxUtilsMw from './middlewares/ctxUtilsMw';
import { SessionMemoryStorage, koaSessMw, socketioSessMw } from './middlewares/sessionMws';
import checkRateLimit from './middlewares/globalRateLimiterMw';
import checkRateLimit from './middlewares/globalRateLimiter';
import checkHttpLoad from './middlewares/httpLoadMonitor';
import cacheControlMw from './middlewares/cacheControlMw';
const console = consoleFactory(modulename);
const nanoid = customAlphabet(dict51, 32);
Expand Down Expand Up @@ -88,7 +89,6 @@ export default class WebServer {
|| error.code === 'ECANCELED'
)) {
console.error(`Probably harmless error on ${ctx.path}`);
console.error('Please be kind and send a screenshot of this error to the txAdmin developer.');
console.dir(error);
}
});
Expand All @@ -102,13 +102,17 @@ export default class WebServer {
this.app.use(topLevelMw);

//Setting up additional middlewares:
const jsonLimit = '16MB';
const panelPublicPath = convars.isDevMode
? path.join(process.env.TXADMIN_DEV_SRC_PATH as string, 'panel/public')
: path.join(txEnv.txAdminResourcePath, 'panel');
this.app.use(KoaServe(path.join(txEnv.txAdminResourcePath, 'web/public'), koaServeOptions));
this.app.use(KoaServe(panelPublicPath, koaServeOptions));
this.app.use(KoaBodyParser({ jsonLimit }));
this.app.use(KoaBodyParser({
// Heavy bodies can cause v8 mem exhaustion during a POST DDoS.
// The heaviest JSON is the /intercom/resources endpoint.
// Conservative estimate: 768kb/300b = 2621 resources
jsonLimit: '768kb',
}));

//Custom stuff
this.sessionStore = new SessionMemoryStorage();
Expand Down Expand Up @@ -163,7 +167,8 @@ export default class WebServer {
//Calls the appropriate callback
try {
// console.debug(`HTTP ${req.method} ${req.url}`);
if (!checkRateLimit(req?.socket?.remoteAddress)) return;
if (!checkHttpLoad()) return;
if (!checkRateLimit(req?.socket?.remoteAddress)) return;
if (req.url.startsWith('/socket.io')) {
(this.io.engine as any).handleRequest(req, res);
} else {
Expand Down Expand Up @@ -192,6 +197,19 @@ export default class WebServer {
};
//@ts-ignore
this.httpServer = HttpClass.createServer(this.httpCallbackHandler.bind(this));
// this.httpServer = HttpClass.createServer((req, res) => {
// // const reqSize = parseInt(req.headers['content-length'] || '0');
// // if (req.method === 'POST' && reqSize > 0) {
// // console.debug(chalk.yellow(bytes(reqSize)), `HTTP ${req.method} ${req.url}`);
// // }

// this.httpCallbackHandler(req, res);
// // if(checkRateLimit(req?.socket?.remoteAddress)){
// // this.httpCallbackHandler(req, res);
// // }else {
// // req.destroy();
// // }
// });
this.httpServer.on('error', listenErrorHandler);

let iface: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,4 +95,3 @@ const checkRateLimit = (remoteAddress: string) => {
}

export default checkRateLimit;

80 changes: 80 additions & 0 deletions core/components/WebServer/middlewares/httpLoadMonitor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
const modulename = 'WebServer:PacketDropper';
import v8 from 'node:v8';
import bytes from 'bytes';
import consoleFactory from '@extras/console';
const console = consoleFactory(modulename);

//Config
const RPM_CHECK_INTERVAL = 800; //multiples of 100 only
const HIGH_LOAD_RPM = 15_000;
const HEAP_USED_LIMIT = 0.9;
const REQ_BLOCKER_DURATION = 60; //seconds

//Vars
let reqCounter = 0;
let lastCheckTime = Date.now();
let isUnderHighLoad = false;
let acceptRequests = true;

//Helpers
const getHeapUsage = () => {
const heapStats = v8.getHeapStatistics();
return {
heapSize: bytes(heapStats.used_heap_size),
heapLimit: bytes(heapStats.heap_size_limit),
heapUsed: (heapStats.used_heap_size / heapStats.heap_size_limit),
};
}

/**
* Protects txAdmin against a massive HTTP load, no matter the source of the requests.
* It will print warnings if the server is under high load and block requests if heap is almost full.
*/
const checkHttpLoad = () => {
if (!acceptRequests) return false;

//Check RPM
reqCounter++;
if (reqCounter >= RPM_CHECK_INTERVAL) {
reqCounter = 0;
const now = Date.now();
const elapsedMs = now - lastCheckTime;
lastCheckTime = now;
const requestsPerMinute = Math.ceil((RPM_CHECK_INTERVAL / elapsedMs) * 60_000);

if (requestsPerMinute > HIGH_LOAD_RPM) {
isUnderHighLoad = true;
console.warn(`txAdmin is under high HTTP load: ~${Math.round(requestsPerMinute / 1000)}k reqs/min.`);
} else {
isUnderHighLoad = false;
// console.debug(`${requestsPerMinute.toLocaleString()} rpm`);
}
}

//Every 100 requests if under high load
if (isUnderHighLoad && reqCounter % 100 === 0) {
const { heapSize, heapLimit, heapUsed } = getHeapUsage();
// console.debug((heapUsed * 100).toFixed(2) + '% heap');
if (heapUsed > HEAP_USED_LIMIT) {
console.majorMultilineError([
`Node.js's V8 engine heap is almost full: ${(heapUsed * 100).toFixed(2)}% (${heapSize}/${heapLimit})`,
`All HTTP requests will be blocked for the next ${REQ_BLOCKER_DURATION} seconds to prevent a crash.`,
'Make sure you have a proper firewall setup and/or a reverse proxy with rate limiting.',
'You can join https://discord.gg/txAdmin for support.'
]);
//Resetting counter
reqCounter = 0;

//Blocking requests + setting a timeout to unblock
acceptRequests = false;
setTimeout(() => {
acceptRequests = true;
console.warn('HTTP requests are now allowed again.');
}, REQ_BLOCKER_DURATION * 1000);
}
}

return acceptRequests;
};

export default checkHttpLoad;
Loading

0 comments on commit ee496b5

Please sign in to comment.