diff --git a/apps/core/src/modules/process/manager.test.ts b/apps/core/src/modules/process/manager.test.ts index 333ebfa7..33901ab9 100644 --- a/apps/core/src/modules/process/manager.test.ts +++ b/apps/core/src/modules/process/manager.test.ts @@ -25,6 +25,7 @@ import { txAdminCompat } from '../txadmin/compat'; const mockGetHistory = mock(() => []); const mockBufferPush = mock(() => {}); +const mockSettingsGet = mock(() => undefined); const mockRegenerateApiToken = mock(() => {}); const mockGetSystemValues = mock(() => ({ @@ -39,6 +40,12 @@ const mockGetFxServerValues = mock(() => ({ serverConfigFile: 'server.cfg', })); +mock.module('@fxmanager/database', () => ({ + repo: { + settings: { get: mockSettingsGet }, + }, +})); + // Use spyOn to safely intercept instances without corrupting Bun's global module cache const getInstanceSpy = spyOn(ConfigManager, 'getInstance').mockReturnValue({ regenerateApiToken: mockRegenerateApiToken, diff --git a/apps/core/src/modules/process/manager.ts b/apps/core/src/modules/process/manager.ts index a180dc75..0c379c4b 100644 --- a/apps/core/src/modules/process/manager.ts +++ b/apps/core/src/modules/process/manager.ts @@ -19,6 +19,7 @@ import { disconnectManager } from '../disconnect/manager'; import { sessionManager } from '../session/manager'; import { gameManager } from '../game/manager'; import { txAdminCompat } from '../txadmin/compat'; +import { repo } from '@fxmanager/database'; const STARTUP_STALL_MS = 90_000; const KILL_GRACE_MS = 5_000; @@ -79,6 +80,10 @@ export class ProcessManager { '+add_convar_permission', 'fxManager', 'read', 'api-port', ]; + const configuredArgs = + repo.settings.get('fxserver.startupArguments')?.split(' ') ?? []; + args.push(...configuredArgs); + console.log(`[core] Starting fxServer`); this.injectConsoleLine({ diff --git a/apps/core/src/routes/api/settings/index.ts b/apps/core/src/routes/api/settings/index.ts index 1c1ffa38..75a1b686 100644 --- a/apps/core/src/routes/api/settings/index.ts +++ b/apps/core/src/routes/api/settings/index.ts @@ -8,7 +8,11 @@ import type { SettingsKey, SettingsScope, } from '@fxmanager/shared/types'; -import { PermissionManager, canWriteSetting } from '@fxmanager/shared/utils'; +import { + PermissionManager, + canWriteSetting, + validateStartupArguments, +} from '@fxmanager/shared/utils'; import { SETTINGS_KEYS, SETTINGS_SCOPES, @@ -18,6 +22,17 @@ import { import { repo } from '@fxmanager/database'; import { restartScheduler } from '../../../modules/schedule/manager'; +interface HookResult { + valid: boolean; + [key: string]: unknown; +} + +const SETTINGS_HOOKS: Partial< + Record HookResult> +> = { + 'fxserver.startupArguments': validateStartupArguments, +}; + const SettingsEndpoints: RouteModule['handler'] = async ( fastify, { pm, gm }, @@ -80,6 +95,11 @@ const SettingsEndpoints: RouteModule['handler'] = async ( throw new Error('Unauthorized'); } + const hook = SETTINGS_HOOKS[key as SettingsKey]; + if (hook && !hook(value).valid) { + throw new Error('Invalid value'); + } + repo.settings.set(key, value); const logValue = SETTINGS_SENSITIVE_KEYS.includes(key as SettingsKey) diff --git a/apps/webpanel/src/pages/settings/tabs/fxserver.tsx b/apps/webpanel/src/pages/settings/tabs/fxserver.tsx index bb04bcd8..fa7905f4 100644 --- a/apps/webpanel/src/pages/settings/tabs/fxserver.tsx +++ b/apps/webpanel/src/pages/settings/tabs/fxserver.tsx @@ -10,6 +10,50 @@ import { SETTINGS_DEFAULTS } from '@fxmanager/shared/constants'; import type { SettingsTabProps } from '@/types/settings'; import { Separator } from '@fxmanager/ui/components/separator'; import { Input } from '@fxmanager/ui/components/input'; +import { Field, FieldDescription } from '@fxmanager/ui/components/field'; +import { validateStartupArguments } from '@fxmanager/shared/utils'; +import { useState } from 'react'; + +function blur(event: React.KeyboardEvent) { + if (event.key !== 'Enter') return; + event.currentTarget.blur(); +} + +function StartupArgumentsField({ + value: defaultValue, + disabled, + onChange, +}: { + value: string; + disabled: boolean; + onChange: (value: string) => void; +}) { + const [error, setError] = useState(''); + + return ( + + { + const value = event.currentTarget.value.trim(); + const validation = validateStartupArguments(value); + + if (validation.valid !== true) { + setError(`"${validation.argument}" is not allowed here.`); + return; + } + + setError(''); + onChange(value); + }} + onKeyDown={blur} + /> + {error} + + ); +} export default function FXServerTab({ data, @@ -18,6 +62,7 @@ export default function FXServerTab({ }: SettingsTabProps<'fxserver'>) { const onesync = data['fxserver.onesync'] ?? SETTINGS_DEFAULTS['fxserver.onesync']; + const startupArguments = data['fxserver.startupArguments'] ?? ''; const serverDataPath = data['fxserver.serverDataPath'] ?? SETTINGS_DEFAULTS['fxserver.serverDataPath']; @@ -28,11 +73,6 @@ export default function FXServerTab({ data['fxserver.serverConfigPath'] ?? SETTINGS_DEFAULTS['fxserver.serverConfigPath']; - function blur(event: React.KeyboardEvent) { - if (event.key !== 'Enter') return; - event.currentTarget.blur(); - } - return (
@@ -52,6 +92,18 @@ export default function FXServerTab({ + + { + if (value === startupArguments) return; + + onChange('fxserver.startupArguments', value); + }} + /> + + diff --git a/packages/shared/src/constants/settings.ts b/packages/shared/src/constants/settings.ts index 12fc44ce..7ad8bcdc 100644 --- a/packages/shared/src/constants/settings.ts +++ b/packages/shared/src/constants/settings.ts @@ -2,7 +2,13 @@ import type { SettingsKey, SettingsKeysByScope } from '../types'; export const SETTINGS_SCOPES = { general: [], - fxserver: ['onesync', 'executablePath', 'serverDataPath', 'serverConfigPath'], + fxserver: [ + 'onesync', + 'startupArguments', + 'executablePath', + 'serverDataPath', + 'serverConfigPath', + ], whitelist: ['mode', 'discordBotToken', 'discordGuildId', 'discordRoleIds'], restarts: ['enabled', 'times'], } as const; @@ -29,7 +35,21 @@ export const SETTINGS_SENSITIVE_KEYS: SettingsKey[] = [ ]; export const SETTINGS_MASTER_ONLY_KEYS: SettingsKey[] = [ + 'fxserver.startupArguments', 'fxserver.executablePath', 'fxserver.serverDataPath', 'fxserver.serverConfigPath', ]; + +export const RESTRICTED_STARTUP_ARGUMENTS: RegExp[] = [ + /^\+set\s+onesync\b/i, + /^\+set\s+resource-api-token\b/i, + /^\+set\s+api-port\b/i, + /^\+set\s+citizen-dir\b/i, + /^\+(ensure|start|stop|restart)\s+fxManager\b/i, + + /^\+add_convar_permission\s+\S+\s+\S+\s+resource-api-token\b/i, + /^\+add_convar_permission\s+\S+\s+\S+\s+api-port\b/i, + + /^--library-path\s+\S+/i, +]; diff --git a/packages/shared/src/utils/index.ts b/packages/shared/src/utils/index.ts index fa607cae..31d3aaf9 100644 --- a/packages/shared/src/utils/index.ts +++ b/packages/shared/src/utils/index.ts @@ -2,3 +2,4 @@ export * from './group-slug'; export * from './permissions'; export * from './resource-name'; export * from './settings-access'; +export * from './startup-arguments'; diff --git a/packages/shared/src/utils/startup-arguments.test.ts b/packages/shared/src/utils/startup-arguments.test.ts new file mode 100644 index 00000000..b2ce7e9d --- /dev/null +++ b/packages/shared/src/utils/startup-arguments.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'bun:test'; +import { validateStartupArguments } from './startup-arguments'; + +describe('validateStartupArguments()', () => { + it('should return true for valid arguments', () => { + expect(validateStartupArguments('+svgui --trace-warnings')).toEqual({ + valid: true, + }); + expect(validateStartupArguments('+exec somecfg.cfg')).toEqual({ + valid: true, + }); + }); + + it('should return the first restricted value for invalid arguments', () => { + expect( + validateStartupArguments('+exec somecfg.cfg +set onesync on'), + ).toEqual({ valid: false, argument: '+set onesync on' }); + expect( + validateStartupArguments('+stop fxManager +set api-port 40120'), + ).toEqual({ valid: false, argument: '+stop fxManager' }); + expect( + validateStartupArguments('--library-path /home/user/somefolder'), + ).toEqual({ + valid: false, + argument: '--library-path /home/user/somefolder', + }); + }); +}); diff --git a/packages/shared/src/utils/startup-arguments.ts b/packages/shared/src/utils/startup-arguments.ts new file mode 100644 index 00000000..be69fe8e --- /dev/null +++ b/packages/shared/src/utils/startup-arguments.ts @@ -0,0 +1,23 @@ +import { RESTRICTED_STARTUP_ARGUMENTS } from '../constants'; + +export type ValidationResult = + | { valid: true } + | { valid: false; argument: string }; + +/** + * Validates the given string for restricted startup arguments and, if invalid, returns the first invalid argument. + * @param value String of arguments to validate. + * @returns `true` if valid, otherwise the first invalid argument. + */ +export function validateStartupArguments(value: string): ValidationResult { + if (!value) return { valid: true }; + + const args = value.match(/(?:\+|--)\S+(?:\s+(?!(?:\+|--))\S+)*/g) ?? []; + const restricted = args.find((arg) => + RESTRICTED_STARTUP_ARGUMENTS.some((patt) => patt.test(arg)), + ); + + if (!restricted) return { valid: true }; + + return { valid: false, argument: restricted }; +}