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 = {}; 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 /** * 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" */