579 lines
17 KiB
TypeScript
579 lines
17 KiB
TypeScript
import os from 'node:os';
|
|
import fsp from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import slash from 'slash';
|
|
|
|
import consoleFactory, { setConsoleEnvData } from '@lib/console';
|
|
import { addLocalIpAddress } from '@lib/host/isIpAddressLocal';
|
|
import { parseFxserverVersion } from '@lib/fxserver/fxsVersionParser';
|
|
import { parseTxDevEnv, TxDevEnvType } from '@shared/txDevEnv';
|
|
import { Overwrite } from 'utility-types';
|
|
import fatalError from '@lib/fatalError';
|
|
import { getNativeVars } from './boot/getNativeVars';
|
|
import { getHostVars, hostEnvVarSchemas } from './boot/getHostVars';
|
|
import { getZapVars } from './boot/getZapVars';
|
|
import { z, ZodSchema } from 'zod';
|
|
import { fromZodError } from 'zod-validation-error';
|
|
import defaultAds from '../dynamicAds2.json';
|
|
import consts from '@shared/consts';
|
|
const console = consoleFactory();
|
|
|
|
|
|
/**
|
|
* MARK: GETTING VARIABLES
|
|
*/
|
|
//Get OSType
|
|
const osTypeVar = os.type();
|
|
let isWindows;
|
|
if (osTypeVar === 'Windows_NT') {
|
|
isWindows = true;
|
|
} else if (osTypeVar === 'Linux') {
|
|
isWindows = false;
|
|
} else {
|
|
fatalError.GlobalData(0, `OS type not supported: ${osTypeVar}`);
|
|
}
|
|
|
|
//Simple env vars
|
|
const ignoreDeprecatedConfigs = process.env?.TXHOST_IGNORE_DEPRECATED_CONFIGS === 'true';
|
|
|
|
|
|
/**
|
|
* MARK: HELPERS
|
|
*/
|
|
const cleanPath = (x: string) => slash(path.normalize(x));
|
|
const handleMultiVar = <T extends ZodSchema>(
|
|
name: string,
|
|
schema: T,
|
|
procenv: z.infer<T> | undefined,
|
|
zapcfg: string | number | undefined,
|
|
convar: any,
|
|
): z.infer<T> | undefined => {
|
|
const alt = zapcfg ?? convar;
|
|
if (alt === undefined) {
|
|
return procenv;
|
|
}
|
|
const whichAlt = zapcfg !== undefined ? 'txAdminZapConfig.json' : 'ConVar';
|
|
if (procenv !== undefined) {
|
|
console.warn(`WARNING: Both the environment variable 'TXHOST_${name}' and the ${whichAlt} equivalent are set. The environment variable will be prioritized.`);
|
|
return procenv;
|
|
}
|
|
const parsed = schema.safeParse(alt);
|
|
if (!parsed.success) {
|
|
fatalError.GlobalData(20, [
|
|
`Invalid value for the TXHOST_${name}-equivalent config in ${whichAlt}.`,
|
|
['Value', alt],
|
|
'For more information: https://aka.cfx.re/txadmin-env-config',
|
|
], fromZodError(parsed.error, { prefix: null }));
|
|
}
|
|
return parsed.data;
|
|
}
|
|
|
|
|
|
/**
|
|
* MARK: DEV ENV
|
|
*/
|
|
type TxDevEnvEnabledType = Overwrite<TxDevEnvType, {
|
|
ENABLED: true;
|
|
SRC_PATH: string, //required in core/webserver, core/getReactIndex.ts
|
|
VITE_URL: string, //required in core/getReactIndex.ts
|
|
}>;
|
|
type TxDevEnvDisabledType = Overwrite<TxDevEnvType, {
|
|
ENABLED: false;
|
|
SRC_PATH: undefined;
|
|
VITE_URL: undefined;
|
|
}>;
|
|
let _txDevEnv: TxDevEnvEnabledType | TxDevEnvDisabledType;
|
|
const devVars = parseTxDevEnv();
|
|
if (devVars.ENABLED) {
|
|
console.debug('Starting txAdmin in DEV mode.');
|
|
if (!devVars.SRC_PATH || !devVars.VITE_URL) {
|
|
fatalError.GlobalData(8, 'Missing TXDEV_VITE_URL and/or TXDEV_SRC_PATH env variables.');
|
|
}
|
|
_txDevEnv = devVars as TxDevEnvEnabledType;
|
|
} else {
|
|
_txDevEnv = {
|
|
...devVars,
|
|
SRC_PATH: undefined,
|
|
VITE_URL: undefined,
|
|
} as TxDevEnvDisabledType;
|
|
}
|
|
|
|
|
|
/**
|
|
* MARK: CHECK HOST VARS
|
|
*/
|
|
const nativeVars = getNativeVars(ignoreDeprecatedConfigs);
|
|
|
|
//Getting fxserver version
|
|
//4380 = GetVehicleType was exposed server-side
|
|
//4548 = more or less when node v16 was added
|
|
//4574 = add missing PRINT_STRUCTURED_TRACE declaration
|
|
//4574 = add resource field to PRINT_STRUCTURED_TRACE
|
|
//5894 = CREATE_VEHICLE_SERVER_SETTER
|
|
//6185 = added ScanResourceRoot (not yet in use)
|
|
//6508 = unhandledRejection is now handlable, we need this due to discord.js's bug
|
|
//8495 = changed prometheus::Histogram::BucketBoundaries
|
|
//9423 = feat(server): add more infos to playerDropped event
|
|
//9655 = Fixed ScanResourceRoot + latent events
|
|
const minFxsVersion = 5894;
|
|
const fxsVerParsed = parseFxserverVersion(nativeVars.fxsVersion);
|
|
const fxsVersion = fxsVerParsed.valid ? fxsVerParsed.build : 99999;
|
|
if (!fxsVerParsed.valid) {
|
|
console.error('It looks like you are running a custom build of fxserver.');
|
|
console.error('And because of that, there is no guarantee that txAdmin will work properly.');
|
|
console.error(`Convar: ${nativeVars.fxsVersion}`);
|
|
console.error(`Parsed Build: ${fxsVerParsed.build}`);
|
|
console.error(`Parsed Branch: ${fxsVerParsed.branch}`);
|
|
console.error(`Parsed Platform: ${fxsVerParsed.platform}`);
|
|
} else if (fxsVerParsed.build < minFxsVersion) {
|
|
fatalError.GlobalData(2, [
|
|
'This version of FXServer is too outdated and NOT compatible with txAdmin',
|
|
['Current FXServer version', fxsVerParsed.build.toString()],
|
|
['Minimum required version', minFxsVersion.toString()],
|
|
'Please update your FXServer to a newer version.',
|
|
]);
|
|
} else if (fxsVerParsed.branch !== 'master') {
|
|
console.warn(`You are running a custom branch of FXServer: ${fxsVerParsed.branch}`);
|
|
}
|
|
|
|
//Getting txAdmin version
|
|
if (!nativeVars.txaResourceVersion) {
|
|
fatalError.GlobalData(3, [
|
|
'txAdmin version not set or in the wrong format.',
|
|
['Detected version', nativeVars.txaResourceVersion],
|
|
]);
|
|
}
|
|
const txaVersion = nativeVars.txaResourceVersion;
|
|
|
|
//Get txAdmin Resource Path
|
|
if (!nativeVars.txaResourcePath) {
|
|
fatalError.GlobalData(4, [
|
|
'Could not resolve txAdmin resource path.',
|
|
['Convar', nativeVars.txaResourcePath],
|
|
]);
|
|
}
|
|
const txaPath = cleanPath(nativeVars.txaResourcePath);
|
|
|
|
//Get citizen Root
|
|
if (!nativeVars.fxsCitizenRoot) {
|
|
fatalError.GlobalData(5, [
|
|
'citizen_root convar not set',
|
|
['Convar', nativeVars.fxsCitizenRoot],
|
|
]);
|
|
}
|
|
const fxsPath = cleanPath(nativeVars.fxsCitizenRoot as string);
|
|
|
|
//Check if server is inside WinRar's temp folder
|
|
if (isWindows && /Temp[\\/]+Rar\$/i.test(fxsPath)) {
|
|
fatalError.GlobalData(12, [
|
|
'It looks like you ran FXServer inside WinRAR without extracting it first.',
|
|
'Please extract the server files to a proper folder before running it.',
|
|
['Server path', fxsPath.replace(/\\/g, '/').replace(/\/$/, '')],
|
|
]);
|
|
}
|
|
|
|
|
|
//Setting the variables in console without it having to importing from here (circular dependency)
|
|
setConsoleEnvData(
|
|
txaVersion,
|
|
txaPath,
|
|
_txDevEnv.ENABLED,
|
|
_txDevEnv.VERBOSE
|
|
);
|
|
|
|
|
|
/**
|
|
* MARK: TXDATA & PROFILE
|
|
*/
|
|
const hostVars = getHostVars();
|
|
//Setting data path
|
|
let hasCustomDataPath = false;
|
|
let dataPath = cleanPath(path.join(
|
|
fxsPath,
|
|
isWindows ? '..' : '../../../',
|
|
'txData'
|
|
));
|
|
const dataPathVar = handleMultiVar(
|
|
'DATA_PATH',
|
|
hostEnvVarSchemas.DATA_PATH,
|
|
hostVars.DATA_PATH,
|
|
undefined,
|
|
nativeVars.txDataPath,
|
|
);
|
|
if (dataPathVar) {
|
|
hasCustomDataPath = true;
|
|
dataPath = cleanPath(dataPathVar);
|
|
}
|
|
|
|
//Check paths for non-ASCII characters
|
|
//NOTE: Non-ASCII in one of those paths (don't know which) will make NodeJS crash due to a bug in v8 (or something)
|
|
// when running localization methods like Date.toLocaleString().
|
|
// There was also an issue with the slash() lib and with the +exec on FXServer
|
|
const nonASCIIRegex = /[^\x00-\x80]+/;
|
|
if (nonASCIIRegex.test(fxsPath) || nonASCIIRegex.test(dataPath)) {
|
|
fatalError.GlobalData(7, [
|
|
'Due to environmental restrictions, your paths CANNOT contain non-ASCII characters.',
|
|
'Example of non-ASCII characters: çâýå, ρέθ, ñäé, ēļæ, глж, เซิร์, 警告.',
|
|
'Please make sure FXServer is not in a path contaning those characters.',
|
|
`If on windows, we suggest you moving the artifact to "C:/fivemserver/${fxsVersion}/".`,
|
|
['FXServer path', fxsPath],
|
|
['txData path', dataPath],
|
|
]);
|
|
}
|
|
|
|
//Profile - not available as env var
|
|
let profileVar = nativeVars.txAdminProfile;
|
|
if (profileVar) {
|
|
profileVar = profileVar.replace(/[^a-z0-9._-]/gi, '');
|
|
if (profileVar.endsWith('.base')) {
|
|
fatalError.GlobalData(13, [
|
|
['Invalid server profile name', profileVar],
|
|
'Profile names cannot end with ".base".',
|
|
'It looks like you are trying to point to a server folder instead of a profile.',
|
|
]);
|
|
}
|
|
if (!profileVar.length) {
|
|
fatalError.GlobalData(14, [
|
|
'Invalid server profile name.',
|
|
'If you are using Google Translator on the instructions page,',
|
|
'make sure there are no additional spaces in your command.',
|
|
]);
|
|
}
|
|
}
|
|
const profileName = profileVar ?? 'default';
|
|
const profilePath = cleanPath(path.join(dataPath, profileName));
|
|
|
|
|
|
/**
|
|
* MARK: ZAP & NETWORKING
|
|
*/
|
|
let zapVars: ReturnType<typeof getZapVars> | undefined;
|
|
if (!ignoreDeprecatedConfigs) {
|
|
//FIXME: ZAP doesn't need this anymore, remove ASAP
|
|
const zapCfgFilePath = path.join(dataPath, 'txAdminZapConfig.json');
|
|
try {
|
|
zapVars = getZapVars(zapCfgFilePath);
|
|
if (!_txDevEnv.ENABLED) fsp.unlink(zapCfgFilePath).catch(() => { });
|
|
} catch (error) {
|
|
fatalError.GlobalData(9, 'Failed to load with ZAP-Hosting configuration.', error);
|
|
}
|
|
}
|
|
|
|
//No default, no convar/zap cfg
|
|
const txaUrl = hostVars.TXA_URL;
|
|
|
|
//txAdmin port
|
|
const txaPort = handleMultiVar(
|
|
'TXA_PORT',
|
|
hostEnvVarSchemas.TXA_PORT,
|
|
hostVars.TXA_PORT,
|
|
zapVars?.txAdminPort,
|
|
nativeVars.txAdminPort,
|
|
) ?? 40120;
|
|
|
|
//fxserver port
|
|
const fxsPort = handleMultiVar(
|
|
'FXS_PORT',
|
|
hostEnvVarSchemas.FXS_PORT,
|
|
hostVars.FXS_PORT,
|
|
zapVars?.forceFXServerPort,
|
|
undefined,
|
|
);
|
|
|
|
//Forced interface
|
|
const netInterface = handleMultiVar(
|
|
'INTERFACE',
|
|
hostEnvVarSchemas.INTERFACE,
|
|
hostVars.INTERFACE,
|
|
zapVars?.forceInterface,
|
|
nativeVars.txAdminInterface,
|
|
);
|
|
if (netInterface) {
|
|
addLocalIpAddress(netInterface);
|
|
}
|
|
|
|
|
|
/**
|
|
* MARK: GENERAL
|
|
*/
|
|
const forceGameName = hostVars.GAME_NAME;
|
|
const hostApiToken = hostVars.API_TOKEN;
|
|
|
|
const forceMaxClients = handleMultiVar(
|
|
'MAX_SLOTS',
|
|
hostEnvVarSchemas.MAX_SLOTS,
|
|
hostVars.MAX_SLOTS,
|
|
zapVars?.deployerDefaults?.maxClients,
|
|
undefined,
|
|
);
|
|
|
|
const forceQuietMode = handleMultiVar(
|
|
'QUIET_MODE',
|
|
hostEnvVarSchemas.QUIET_MODE,
|
|
hostVars.QUIET_MODE,
|
|
zapVars?.deployerDefaults?.maxClients,
|
|
undefined,
|
|
) ?? false;
|
|
|
|
|
|
/**
|
|
* MARK: PROVIDER
|
|
*/
|
|
const providerName = handleMultiVar(
|
|
'PROVIDER_NAME',
|
|
hostEnvVarSchemas.PROVIDER_NAME,
|
|
hostVars.PROVIDER_NAME,
|
|
zapVars?.providerName,
|
|
undefined,
|
|
);
|
|
const providerLogo = handleMultiVar(
|
|
'PROVIDER_LOGO',
|
|
hostEnvVarSchemas.PROVIDER_LOGO,
|
|
hostVars.PROVIDER_LOGO,
|
|
zapVars?.loginPageLogo,
|
|
undefined,
|
|
);
|
|
|
|
|
|
/**
|
|
* MARK: DEFAULTS
|
|
*/
|
|
const defaultDbHost = handleMultiVar(
|
|
'DEFAULT_DBHOST',
|
|
hostEnvVarSchemas.DEFAULT_DBHOST,
|
|
hostVars.DEFAULT_DBHOST,
|
|
zapVars?.deployerDefaults?.mysqlHost,
|
|
undefined,
|
|
);
|
|
const defaultDbPort = handleMultiVar(
|
|
'DEFAULT_DBPORT',
|
|
hostEnvVarSchemas.DEFAULT_DBPORT,
|
|
hostVars.DEFAULT_DBPORT,
|
|
zapVars?.deployerDefaults?.mysqlPort,
|
|
undefined,
|
|
);
|
|
const defaultDbUser = handleMultiVar(
|
|
'DEFAULT_DBUSER',
|
|
hostEnvVarSchemas.DEFAULT_DBUSER,
|
|
hostVars.DEFAULT_DBUSER,
|
|
zapVars?.deployerDefaults?.mysqlUser,
|
|
undefined,
|
|
);
|
|
const defaultDbPass = handleMultiVar(
|
|
'DEFAULT_DBPASS',
|
|
hostEnvVarSchemas.DEFAULT_DBPASS,
|
|
hostVars.DEFAULT_DBPASS,
|
|
zapVars?.deployerDefaults?.mysqlPassword,
|
|
undefined,
|
|
);
|
|
const defaultDbName = handleMultiVar(
|
|
'DEFAULT_DBNAME',
|
|
hostEnvVarSchemas.DEFAULT_DBNAME,
|
|
hostVars.DEFAULT_DBNAME,
|
|
zapVars?.deployerDefaults?.mysqlDatabase,
|
|
undefined,
|
|
);
|
|
|
|
//Default Master Account
|
|
type DefaultMasterAccount = {
|
|
username: string;
|
|
fivemId?: string;
|
|
password?: string;
|
|
} | {
|
|
username: string;
|
|
password: string;
|
|
} | undefined;
|
|
let defaultMasterAccount: DefaultMasterAccount;
|
|
const bcryptRegex = /^\$2[aby]\$[0-9]{2}\$[A-Za-z0-9./]{53}$/;
|
|
if (hostVars.DEFAULT_ACCOUNT) {
|
|
let [username, fivemId, password] = hostVars.DEFAULT_ACCOUNT.split(':') as (string | undefined)[];
|
|
if (username === '') username = undefined;
|
|
if (fivemId === '') fivemId = undefined;
|
|
if (password === '') password = undefined;
|
|
|
|
const errArr: [string, any][] = [
|
|
['Username', username],
|
|
['FiveM ID', fivemId],
|
|
['Password', password],
|
|
];
|
|
if (!username || !consts.regexValidFivemUsername.test(username)) {
|
|
fatalError.GlobalData(21, [
|
|
'Invalid default account username.',
|
|
'It should be a valid FiveM username.',
|
|
...errArr,
|
|
]);
|
|
}
|
|
if (fivemId && !consts.validIdentifierParts.fivem.test(fivemId)) {
|
|
fatalError.GlobalData(22, [
|
|
'Invalid default account FiveM ID.',
|
|
'It should match the number in the fivem:0000000 game identifier.',
|
|
...errArr,
|
|
]);
|
|
}
|
|
if (password && !bcryptRegex.test(password)) {
|
|
fatalError.GlobalData(23, [
|
|
'Invalid default account password.',
|
|
'Expected bcrypt hash.',
|
|
...errArr,
|
|
]);
|
|
}
|
|
if (!fivemId && !password) {
|
|
fatalError.GlobalData(24, [
|
|
'Invalid default account.',
|
|
'Expected at least the FiveM ID or password to be present.',
|
|
...errArr,
|
|
]);
|
|
}
|
|
defaultMasterAccount = {
|
|
username,
|
|
fivemId,
|
|
password,
|
|
};
|
|
} else if (zapVars?.defaultMasterAccount) {
|
|
const username = zapVars.defaultMasterAccount?.name;
|
|
const password = zapVars.defaultMasterAccount?.password_hash;
|
|
if (!consts.regexValidFivemUsername.test(username)) {
|
|
fatalError.GlobalData(25, [
|
|
'Invalid default account username.',
|
|
'It should be a valid FiveM username.',
|
|
['Username', username],
|
|
]);
|
|
}
|
|
if (!bcryptRegex.test(password)) {
|
|
fatalError.GlobalData(26, [
|
|
'Invalid default account password.',
|
|
'Expected bcrypt hash.',
|
|
['Hash', password],
|
|
]);
|
|
}
|
|
defaultMasterAccount = {
|
|
username: username,
|
|
password: password,
|
|
};
|
|
}
|
|
|
|
//Default cfx key
|
|
const defaultCfxKey = handleMultiVar(
|
|
'DEFAULT_CFXKEY',
|
|
hostEnvVarSchemas.DEFAULT_CFXKEY,
|
|
hostVars.DEFAULT_CFXKEY,
|
|
zapVars?.deployerDefaults?.license,
|
|
undefined,
|
|
);
|
|
|
|
|
|
/**
|
|
* MARK: FINAL SETUP
|
|
*/
|
|
if (ignoreDeprecatedConfigs) {
|
|
console.verbose.debug('TXHOST_IGNORE_DEPRECATED_CONFIGS is set to true. Ignoring deprecated configs.');
|
|
}
|
|
|
|
const isPterodactyl = !isWindows && process.env?.TXADMIN_ENABLE === '1';
|
|
const isZapHosting = providerName === 'ZAP-Hosting';
|
|
|
|
//Quick config to disable ads
|
|
const displayAds = process.env?.TXHOST_TMP_HIDE_ADS !== 'true' || isPterodactyl || isZapHosting;
|
|
const adSchema = z.object({
|
|
img: z.string(),
|
|
url: z.string(),
|
|
}).nullable();
|
|
const adsDataSchema = z.object({
|
|
login: adSchema,
|
|
main: adSchema,
|
|
});
|
|
let adsData: z.infer<typeof adsDataSchema> = {
|
|
login: null,
|
|
main: null,
|
|
};
|
|
if (displayAds) {
|
|
try {
|
|
adsData = adsDataSchema.parse(defaultAds);
|
|
} catch (error) {
|
|
console.error('Failed to load ads data.', error);
|
|
}
|
|
}
|
|
|
|
//FXServer Display Version
|
|
let fxsVersionTag = fxsVersion.toString();
|
|
if (fxsVerParsed.branch && fxsVerParsed.branch !== 'master') {
|
|
fxsVersionTag += '-ft';
|
|
}
|
|
if (isZapHosting) {
|
|
fxsVersionTag += '/ZAP';
|
|
} else if (isPterodactyl) {
|
|
fxsVersionTag += '/Ptero';
|
|
} else if (isWindows && fxsVerParsed.platform === 'windows') {
|
|
fxsVersionTag += '/Win';
|
|
} else if (!isWindows && fxsVerParsed.platform === 'linux') {
|
|
fxsVersionTag += '/Lin';
|
|
} else {
|
|
fxsVersionTag += '/Unk';
|
|
}
|
|
|
|
|
|
/**
|
|
* MARK: Exports
|
|
*/
|
|
export const txDevEnv = Object.freeze(_txDevEnv);
|
|
|
|
export const txEnv = Object.freeze({
|
|
//Calculated
|
|
isWindows,
|
|
isPterodactyl, //TODO: remove, used only in HB Data
|
|
isZapHosting, //TODO: remove, used only in HB Data and authLogic to disable src check
|
|
displayAds,
|
|
adsData,
|
|
|
|
//Natives
|
|
fxsVersionTag,
|
|
fxsVersion,
|
|
txaVersion,
|
|
txaPath,
|
|
fxsPath,
|
|
|
|
//ConVar
|
|
profileName,
|
|
profilePath, //FIXME: replace by profileSubPath in most places
|
|
profileSubPath: (...parts: string[]) => path.join(profilePath, ...parts),
|
|
});
|
|
|
|
export const txHostConfig = Object.freeze({
|
|
//General
|
|
dataPath,
|
|
dataSubPath: (...parts: string[]) => path.join(dataPath, ...parts),
|
|
hasCustomDataPath,
|
|
forceGameName,
|
|
forceMaxClients,
|
|
forceQuietMode,
|
|
hostApiToken,
|
|
|
|
//Networking
|
|
txaUrl,
|
|
txaPort,
|
|
fxsPort,
|
|
netInterface,
|
|
|
|
//Provider
|
|
providerName,
|
|
providerLogo,
|
|
sourceName: providerName ?? 'Host Config',
|
|
|
|
//Defaults
|
|
defaults: {
|
|
account: defaultMasterAccount,
|
|
cfxKey: defaultCfxKey,
|
|
dbHost: defaultDbHost,
|
|
dbPort: defaultDbPort,
|
|
dbUser: defaultDbUser,
|
|
dbPass: defaultDbPass,
|
|
dbName: defaultDbName,
|
|
},
|
|
});
|
|
|
|
|
|
//DEBUG
|
|
// console.dir(txEnv, { compact: true });
|
|
// console.dir(txDevEnv, { compact: true });
|
|
// console.dir(txHostConfig, { compact: true });
|