monitor/core/lib/fxserver/fxsConfigHelper.ts
2025-04-16 22:30:27 +07:00

777 lines
25 KiB
TypeScript

import fs from 'node:fs';
import fsp from 'node:fs/promises';
import path from 'node:path';
import isLocalhost from 'is-localhost-ip';
import { txHostConfig } from '@core/globalData';
import consoleFactory from '@lib/console';
const console = consoleFactory();
/**
* Detect the dominant newline character of a string.
* Extracted from https://www.npmjs.com/package/detect-newline
*/
const detectNewline = (str: string) => {
if (typeof str !== 'string') {
throw new TypeError('Expected a string');
}
const newlines = str.match(/(?:\r?\n)/g) || [];
if (newlines.length === 0) {
return;
}
const crlf = newlines.filter((newline) => newline === '\r\n').length;
const lf = newlines.length - crlf;
return crlf > lf ? '\r\n' : '\n';
};
/**
* Helper function to store commands
*/
class Command {
readonly command: string;
readonly args: string[];
readonly file: string;
readonly line: number;
constructor(tokens: string[], filePath: string, fileLine: number) {
if (!Array.isArray(tokens) || tokens.length < 1) {
throw new Error('Invalid command format');
}
if (typeof tokens[0] === 'string' && tokens[0].length) {
this.command = tokens[0].toLocaleLowerCase();
} else {
this.command = 'invalid_empty_command';
}
this.args = tokens.slice(1);
this.file = filePath;
this.line = fileLine;
}
//Kinda confusing name, but it returns the value of a set if it's for that ne var
isConvarSetterFor(varname: string) {
if (
['set', 'sets', 'setr'].includes(this.command)
&& this.args.length === 2
&& this.args[0].toLowerCase() === varname.toLowerCase()
) {
return this.args[1];
}
if (
this.command === varname.toLowerCase()
&& this.args.length === 1
) {
return this.args[0];
}
return false;
}
}
/**
* Helper function to store exec errors
*/
class ExecRecursionError {
constructor(readonly file: string, readonly message: string, readonly line: number) { }
}
/**
* Helper class to store file TODOs, errors and warnings
*/
class FilesInfoList {
readonly store: Record<string, [number | false, string][]> = {};
add(file: string, line: number | false, msg: string) {
if (Array.isArray(this.store[file])) {
this.store[file].push([line, msg]);
} else {
this.store[file] = [[line, msg]];
}
}
count() {
return Object.keys(this.store).length;
}
toJSON() {
return this.store;
}
toMarkdown(hasHostConfig = false) {
const files = Object.keys(this.store);
if (!files) return null;
const msgLines = [];
for (const file of files) {
const fileInfos = this.store[file];
msgLines.push(`\`${file}\`:`);
for (const [line, msg] of fileInfos) {
const linePrefix = line ? `Line ${line}: ` : '';
const indentedMsg = msg.replaceAll(/\n\t/gm, '\n\t- ');
msgLines.push(`- ${linePrefix}${indentedMsg}`);
}
}
if (hasHostConfig) {
msgLines.push(''); //blank line so the warning doesn't join the list
msgLines.push(`**Some of the configuration above is controlled by ${txHostConfig.sourceName}.**`);
}
return msgLines.join('\n');
}
}
/**
* Returns the first likely server.cfg given a server data path, or false
*/
export const findLikelyCFGPath = (serverDataPath: string) => {
const commonCfgFileNames = [
'server.cfg',
'server.cfg.txt',
'server.cfg.cfg',
'server.txt',
'server',
];
for (const cfgFileName of commonCfgFileNames) {
const absoluteCfgPath = path.join(serverDataPath, cfgFileName);
try {
if (fs.lstatSync(absoluteCfgPath).isFile()) {
return cfgFileName;
}
} catch (error) { }
}
return false;
}
/**
* Returns the absolute path of the given CFG Path
*/
export const resolveCFGFilePath = (cfgPath: string, dataPath: string) => {
return (path.isAbsolute(cfgPath)) ? path.normalize(cfgPath) : path.resolve(dataPath, cfgPath);
};
/**
* Reads CFG Path and return the file contents, or throw error if:
* - the path is not valid (must be absolute)
* - cannot read the file data
*/
export const readRawCFGFile = async (cfgPath: string) => {
//Validating if the path is absolute
if (!path.isAbsolute(cfgPath)) {
throw new Error('File path must be absolute.');
}
//Validating file existence
if (!fs.existsSync(cfgPath)) {
throw new Error("File doesn't exist or its unreadable.");
}
//Validating if its actually a file
if (!fs.lstatSync(cfgPath).isFile()) {
throw new Error("File doesn't exist or its unreadable. Make sure to include the CFG file in the path, and not just the directory that contains it.");
}
//Reading file
try {
return await fsp.readFile(cfgPath, 'utf8');
} catch (error) {
throw new Error('Cannot read CFG file.');
}
};
/**
* Parse a cfg/console line and return an array of commands with tokens.
* Notable difference: we don't handle inline block comment
* Original Line Parser:
* fivem/code/client/citicore/console/Console.cpp > ProgramArguments Tokenize
*/
export const readLineCommands = (input: string) => {
let inQuote = false;
let inEscape = false;
const prevCommands = [];
let currCommand = [];
let currToken = '';
for (let i = 0; i < input.length; i++) {
if (inEscape) {
if (input[i] === '"' || input[i] === '\\') {
currToken += input[i];
}
inEscape = false;
continue;
}
if (!currToken.length) {
if (
input.slice(i, i + 2) === '//'
|| input[i] === '#'
) {
break;
}
}
if (!inQuote && input.charCodeAt(i) <= 32) {
if (currToken.length) {
currCommand.push(currToken);
currToken = '';
}
continue;
}
if (input[i] === '"') {
if (inQuote) {
currCommand.push(currToken);
currToken = '';
inQuote = false;
} else {
inQuote = true;
}
continue;
}
if (input[i] === '\\') {
inEscape = true;
continue;
}
if (!inQuote && input[i] === ';') {
if (currToken.length) {
currCommand.push(currToken);
currToken = '';
}
prevCommands.push(currCommand);
currCommand = [];
continue;
};
currToken += input[i];
}
if (currToken.length) {
currCommand.push(currToken);
}
prevCommands.push(currCommand);
return prevCommands;
};
//NOTE: tests for the parser above
// import chalk from 'chalk';
// const testCommands = [
// ' \x1B ONE_ARG_WITH_SPACE "part1 part2"',
// 'TWO_ARGS arg1 arg2',
// 'ONE_ARG_WITH_SPACE_SEMICOLON "arg mid;cut"',
// 'ESCAPED_QUOTE "aa\\"bb"',
// 'ESCAPED_ESCAPE "aa\\\\bb"',
// 'ESCAPED_X "aa\\xbb"',
// // 'NO_CLOSING_QUOTE "aa',
// // 'SHOW_AB_C aaa#bbb ccc',
// // 'COMMENT //anything noshow',
// 'COMMENT #anything noshow',
// 'noshow2',
// ];
// const parsed = readLineCommands(testCommands.join(';'));
// for (const commandTokens of parsed) {
// console.log(`${commandTokens[0]}:`);
// commandTokens.slice(1).forEach((token) => {
// console.log(chalk.inverse(token));
// });
// console.log('\n');
// }
/**
* Recursively parse server.cfg files losely based on the FXServer original parser.
* Notable differences: we have recursivity depth limit, and no json parsing
* Original CFG (console) parser:
* fivem/code/client/citicore/console/Console.cpp > Context::ExecuteBuffer
*
* FIXME: support `@resource/whatever.cfg` syntax
*/
export const parseRecursiveConfig = async (
cfgInputString: string | null, //cfg string, or null to load from file
cfgAbsolutePath: string,
serverDataPath: string,
stack?: string[]
) => {
if (typeof cfgInputString !== 'string' && cfgInputString !== null) {
throw new Error('cfgInputString expected to be string or null');
}
// Ensure safe stack
const MAX_DEPTH = 5;
if (!Array.isArray(stack)) {
stack = [];
} else if (stack.length >= MAX_DEPTH) {
throw new Error(`cfg 'exec' command depth above ${MAX_DEPTH}`);
} else if (stack.includes(cfgAbsolutePath)) {
throw new Error(`cfg cyclical 'exec' command detected to file ${cfgAbsolutePath}`); //should block
}
stack.push(cfgAbsolutePath);
// Read raw config and split lines
const cfgData = cfgInputString ?? await readRawCFGFile(cfgAbsolutePath);
const cfgLines = cfgData.split('\n');
// Parse CFG lines
const parsedCommands: (Command | ExecRecursionError)[] = [];
for (let i = 0; i < cfgLines.length; i++) {
const lineString = cfgLines[i].trim();
const lineNumber = i + 1;
const lineCommands = readLineCommands(lineString);
// For each command in that line
for (const cmdTokens of lineCommands) {
if (!cmdTokens.length) continue;
const cmdObject = new Command(cmdTokens, cfgAbsolutePath, lineNumber);
parsedCommands.push(cmdObject);
// If exec command, process recursively then flatten the output
if (cmdObject.command === 'exec' && typeof cmdObject.args[0] === 'string') {
//FIXME: temporarily disable resoure references
if (!cmdObject.args[0].startsWith('@')) {
const recursiveCfgAbsolutePath = resolveCFGFilePath(cmdObject.args[0], serverDataPath);
try {
const extractedCommands = await parseRecursiveConfig(null, recursiveCfgAbsolutePath, serverDataPath, stack);
parsedCommands.push(...extractedCommands);
} catch (error) {
parsedCommands.push(new ExecRecursionError(cfgAbsolutePath, (error as Error).message, lineNumber));
}
}
}
}
}
stack.pop();
return parsedCommands;
};
type EndpointsObjectType = Record<string, { tcp?: true; udp?: true; }>
/**
* Validates a list of parsed commands to return endpoints, errors, warnings and lines to comment out
*/
const validateCommands = async (parsedCommands: (ExecRecursionError | Command)[]) => {
const checkedInterfaces = new Map();
let detectedGameName: string | undefined;
const requiredGameName = txHostConfig.forceGameName
? txHostConfig.forceGameName === 'fivem' ? 'gta5' : 'rdr3'
: undefined;
//To return
let hasHostConfigMessage = false;
let hasEndpointCommand = false;
const endpoints: EndpointsObjectType = {};
const errors = new FilesInfoList();
const warnings = new FilesInfoList();
const toCommentOut = new FilesInfoList();
for (const cmd of parsedCommands) {
//In case of error
if (cmd instanceof ExecRecursionError) {
warnings.add(cmd.file, cmd.line, cmd.message);
continue;
}
//Check for +set
if (['+set', '+setr', '+setr'].includes(cmd.command)) {
const msg = `Line ${cmd.line}: remove the '+' from '${cmd.command}', as this is not an launch parameter.`;
warnings.add(cmd.file, cmd.line, msg);
continue;
}
//Check for start/stop/ensure txAdmin/txAdminClient/monitor
if (
['start', 'stop', 'ensure'].includes(cmd.command)
&& cmd.args.length >= 1
&& ['txadmin', 'txadminclient', 'monitor'].includes(cmd.args[0].toLowerCase())
) {
toCommentOut.add(
cmd.file,
cmd.line,
'you MUST NOT start/stop/ensure txadmin resources.',
);
continue;
}
//Check sv_maxClients against TXHOST config
const isMaxClientsString = cmd.isConvarSetterFor('sv_maxclients');
if (
txHostConfig.forceMaxClients
&& isMaxClientsString
) {
const maxClients = parseInt(isMaxClientsString);
if (maxClients > txHostConfig.forceMaxClients) {
hasHostConfigMessage = true;
errors.add(
cmd.file,
cmd.line,
`your 'sv_maxclients' MUST be <= ${txHostConfig.forceMaxClients}.`
);
continue;
}
}
//Check gamename against TXHOST config
const isGameNameString = cmd.isConvarSetterFor('gamename');
if (isGameNameString && detectedGameName) {
errors.add(
cmd.file,
cmd.line,
`you already set the 'gamename' to '${detectedGameName}', please remove this line.`
);
continue;
}
if (
txHostConfig.forceGameName
&& isGameNameString
) {
detectedGameName = isGameNameString;
if (isGameNameString !== requiredGameName) {
hasHostConfigMessage = true;
errors.add(
cmd.file,
cmd.line,
`your 'gamename' MUST be '${requiredGameName}'.`
);
continue;
}
}
//Comment out any onesync sets
if (cmd.isConvarSetterFor('onesync')) {
toCommentOut.add(
cmd.file,
cmd.line,
'onesync MUST only be set in the txAdmin settings page.',
);
continue;
}
//FIXME: add isConvarSetterFor for all "Settings page only" convars
//Extract & process endpoint validity
if (cmd.command === 'endpoint_add_tcp' || cmd.command === 'endpoint_add_udp') {
hasEndpointCommand = true;
//Validating args length
if (cmd.args.length !== 1) {
warnings.add(
cmd.file,
cmd.line,
`the \`endpoint_add_*\` commands MUST have exactly 1 argument (received ${cmd.args.length})`
);
continue;
}
//Extracting parts & validating format
const endpointsRegex = /^\[?(([0-9.]{7,15})|([a-z0-9:]{2,29}))\]?:(\d{1,5})$/gi;
const matches = [...cmd.args[0].matchAll(endpointsRegex)];
if (!Array.isArray(matches) || !matches.length) {
errors.add(
cmd.file,
cmd.line,
`the \`${cmd.args[0]}\` is not in a valid \`ip:port\` format.`
);
continue;
}
const [_matchedString, iface, ipv4, ipv6, portString] = matches[0];
//Checking if that interface is available to binding
let canBind = checkedInterfaces.get(iface);
if (typeof canBind === 'undefined') {
canBind = await isLocalhost(iface, true);
checkedInterfaces.set(iface, canBind);
}
if (canBind === false) {
errors.add(
cmd.file,
cmd.line,
`the \`${cmd.command}\` interface \`${iface}\` is not available for this host.`
);
continue;
}
if (txHostConfig.netInterface && iface !== txHostConfig.netInterface) {
hasHostConfigMessage = true;
errors.add(
cmd.file,
cmd.line,
`the \`${cmd.command}\` interface MUST be \`${txHostConfig.netInterface}\`.`
);
continue;
}
//Validating port
const port = parseInt(portString);
if (port >= 40120 && port <= 40150) {
errors.add(
cmd.file,
cmd.line,
`the \`${cmd.command}\` port \`${port}\` is dedicated for txAdmin and CAN NOT be used for FXServer.`
);
continue;
}
if (port === txHostConfig.txaPort) {
errors.add(
cmd.file,
cmd.line,
`the \`${cmd.command}\` port \`${port}\` is being used by txAdmin and CAN NOT be used for FXServer at the same time.`
);
continue;
}
if (txHostConfig.fxsPort && port !== txHostConfig.fxsPort) {
hasHostConfigMessage = true;
errors.add(
cmd.file,
cmd.line,
`the \`${cmd.command}\` port MUST be \`${txHostConfig.fxsPort}\`.`
);
continue;
}
//Add to the endpoint list and check duplicity
const endpoint = (ipv4) ? `${ipv4}:${port}` : `[${ipv6}]:${port}`;
const protocol = (cmd.command === 'endpoint_add_tcp') ? 'tcp' : 'udp';
if (typeof endpoints[endpoint] === 'undefined') {
endpoints[endpoint] = {};
}
if (endpoints[endpoint][protocol]) {
errors.add(
cmd.file,
cmd.line,
`you CANNOT execute \`${cmd.command}\` twice for the interface \`${endpoint}\`.`
);
continue;
} else {
endpoints[endpoint][protocol] = true;
}
}
}
//Since gta5 is the default, we need to check TXHOST for redm
if (txHostConfig.forceGameName === 'redm' && detectedGameName !== 'rdr3') {
const initFile = parsedCommands[0]?.file ?? 'unknown';
hasHostConfigMessage = true;
errors.add(
initFile,
false,
`your config MUST have a 'gamename' set to '${requiredGameName}'.`
);
}
return {
endpoints,
hasEndpointCommand,
hasHostConfigMessage,
errors,
warnings,
toCommentOut,
};
};
/**
* Process endpoints object, checks validity, and then returns a connection string
*/
const getConnectEndpoint = (endpoints: EndpointsObjectType, hasEndpointCommand: boolean) => {
if (!Object.keys(endpoints).length) {
const instruction = hasEndpointCommand
? 'Please delete all \`endpoint_add_*\` lines and'
: 'Please'
const suggestedPort = txHostConfig.fxsPort ?? 30120;
const suggestedInterface = txHostConfig.netInterface ?? '0.0.0.0';
const desidredEndpoint = `${suggestedInterface}:${suggestedPort}`;
const msg = [
`Your config file does not specify a valid endpoints for FXServer to use. ${instruction} add the following to the start of the file:`,
`\t\`endpoint_add_tcp "${desidredEndpoint}"\``,
`\t\`endpoint_add_udp "${desidredEndpoint}"\``,
].join('\n');
throw new Error(msg);
}
const tcpudpEndpoint = Object.keys(endpoints).find((ep) => {
return endpoints[ep].tcp && endpoints[ep].udp;
});
if (!tcpudpEndpoint) {
throw new Error('Your config file does not not contain a ip:port used in both `endpoint_add_tcp` and `endpoint_add_udp` commands. Players would not be able to connect.');
}
return tcpudpEndpoint.replace(/(0\.0\.0\.0|\[::\])/, '127.0.0.1');
};
/**
* Validates & ensures correctness in FXServer config file recursively.
* Used when trying to start server, or validate the server.cfg.
* Returns errors, warnings and connectEndpoint
*/
export const validateFixServerConfig = async (cfgPath: string, serverDataPath: string) => {
//Parsing FXServer config & going through each command
const cfgAbsolutePath = resolveCFGFilePath(cfgPath, serverDataPath);
const parsedCommands = await parseRecursiveConfig(null, cfgAbsolutePath, serverDataPath);
const {
endpoints,
hasEndpointCommand,
hasHostConfigMessage,
errors,
warnings,
toCommentOut
} = await validateCommands(parsedCommands);
//Validating if a valid endpoint was detected
let connectEndpoint: string | null = null;
try {
connectEndpoint = getConnectEndpoint(endpoints, hasEndpointCommand);
} catch (error) {
errors.add(cfgAbsolutePath, false, (error as Error).message);
}
//Commenting out lines or registering them as warnings
for (const targetCfgPath in toCommentOut.store) {
const actions = toCommentOut.store[targetCfgPath];
try {
const cfgRaw = await fsp.readFile(targetCfgPath, 'utf8');
//modify the cfg lines
const fileEOL = detectNewline(cfgRaw);
const cfgLines = cfgRaw.split(/\r?\n/);
for (const [ln, reason] of actions) {
if (ln === false) continue;
if (typeof cfgLines[ln - 1] !== 'string') {
throw new Error(`Line ${ln} not found.`);
}
cfgLines[ln - 1] = `## [txAdmin CFG validator]: ${reason}${fileEOL}# ${cfgLines[ln - 1]}`;
warnings.add(targetCfgPath, ln, `Commented out: ${reason}`);
}
//Saving modified lines
const newCfg = cfgLines.join(fileEOL);
console.warn(`Saving modified file '${targetCfgPath}'`);
await fsp.writeFile(targetCfgPath, newCfg, 'utf8');
} catch (error) {
console.verbose.error(error);
for (const [ln, reason] of actions) {
errors.add(targetCfgPath, ln, `Please comment out this line: ${reason}`);
}
}
}
//Prepare response
return {
connectEndpoint,
errors: errors.toMarkdown(hasHostConfigMessage),
warnings: warnings.toMarkdown(hasHostConfigMessage),
// errors: errors.store,
// warnings: warnings.store,
// endpoints, //Not being used
};
};
/**
* Validating config contents + saving file and backup.
* In case of any errors, it does not save the contents.
* Does not comment out (fix) bad lines.
* Used whenever a user wants to modify server.cfg.
* Returns if saved, and warnings
*/
export const validateModifyServerConfig = async (
cfgInputString: string,
cfgPath: string,
serverDataPath: string
) => {
if (typeof cfgInputString !== 'string') {
throw new Error('cfgInputString expected to be string.');
}
//Parsing FXServer config & going through each command
const cfgAbsolutePath = resolveCFGFilePath(cfgPath, serverDataPath);
const parsedCommands = await parseRecursiveConfig(cfgInputString, cfgAbsolutePath, serverDataPath);
const {
endpoints,
hasEndpointCommand,
hasHostConfigMessage,
errors,
warnings,
toCommentOut
} = await validateCommands(parsedCommands);
//Validating if a valid endpoint was detected
try {
const _connectEndpoint = getConnectEndpoint(endpoints, hasEndpointCommand);
} catch (error) {
errors.add(cfgAbsolutePath, false, (error as Error).message);
}
//If there are any errors
if (errors.count()) {
return {
success: false,
errors: errors.toMarkdown(hasHostConfigMessage),
warnings: warnings.toMarkdown(hasHostConfigMessage),
};
}
//Save file + backup
try {
console.warn(`Saving modified file '${cfgAbsolutePath}'`);
await fsp.copyFile(cfgAbsolutePath, `${cfgAbsolutePath}.bkp`);
await fsp.writeFile(cfgAbsolutePath, cfgInputString, 'utf8');
} catch (error) {
throw new Error(`Failed to edit 'server.cfg' with error: ${(error as Error).message}`);
}
return {
success: true,
warnings: warnings.toMarkdown(),
};
};
/*
fxrunner spawnServer: recursive validate file, get endpoint
settings handleFXServer: recursive validate file
setup handleValidateCFGFile: recursive validate file
setup handleSaveLocal: recursive validate file
cfgEditor CFGEditorSave: validate string, save
deployer handleSaveConfig: validate string, save *
*/
/*
# Endpoints test cases:
/"\[?(([0-9.]{7,15})|([a-z0-9:]{2,29}))\]?:(\d{1,5})"/gmi
# default
"0.0.0.0:30120"
"[0.0.0.0]:30120"
"0.0.0.0"
"[0.0.0.0]"
# ipv6/ipv4
"[::]:30120"
":::30120"
"[::]"
# ipv6 only
"fe80::4cec:1264:187e:ce2b:30120"
"[fe80::4cec:1264:187e:ce2b]:30120"
"::1:30120"
"[::1]:30120"
"[fe80::4cec:1264:187e:ce2b]"
"::1"
"[::1]"
# FXServer doesn't accept
"::1.30120"
"::1 port 30120"
"::1p30120"
"::1#30120"
"::"
# FXServer misreads last part as a port
"fe80::4cec:1264:187e:ce2b"
*/